Week 14: Application and Interface Programming
Planted May 6, 2026

Overview
This week in Fab Academy was about connecting software to a real device in a practical way. For my assignment, I decided to build something directly useful for my final project: a small drawing app that creates custom sleep screens for my e-ink display.

Group Assignment
The group task was to compare as many interface and application tool options as possible. We focused on what is realistic for quick prototyping versus what scales better for robust systems.
We compared:
- Programming language choices
- Device communication options (serial, I2C, sockets, MQTT)
- Data formats (files, JSON, spreadsheet, database)
- UI frameworks (desktop and web)
- Graphics options (Canvas, SVG, WebGL)
- AI coding tools and practical limitations
This comparison helped me choose a browser + MicroPython architecture because it gave me the fastest build-test loop.
Group app link: fakre group application
Individual Assignment
For the individual task, I wrote an application that interfaces the user with an output device I made (my e-ink module on Pico W). My goal was not only to make it work, but to make it easy to reuse later in final-project development.
Main deliverables:
- Web drawing interface (250x122 monochrome)
- Backend running on Pico W using MicroPython
- HTTP endpoints for status, draw, and clear
- Partial/full e-ink refresh workflow
Development
System flow
- User draws on browser canvas
- Canvas is converted to 1-bit packed bytes
- Browser sends bytes to
/draw - Pico W writes bytes into e-ink framebuffer
- Display refreshes in partial or full mode
In practice, this flow felt very close to the Fab Academy spirit: make a tool, test it physically, fail quickly, and improve immediately.

Build and test photos
Browser UI during drawing and send workflow:
maze on black

mandelbrot

Hardware-side testing and live e-ink update checks:
complex flower

honeycomb

ed.)

App interface (HTML/CSS/JS)
The drawing UI served to the browser lives in canvas_server.py as the HTML_PAGE embedded string. Extracted copy for highlight/read:
Download: canvas_app_interface.html
Full browser app interface source (canvas_app_interface.html)
<!-- Snapshot of HTML_PAGE from canvas_server.py — regenerate if the embedded UI changes. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>PICO E-INK CANVAS</title>
<style>
:root{--ink:#111;--paper:#f4f1ea;--rule:#111;--accent:#ff3b30;--mute:#7a7268;}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);
font-family:"JetBrains Mono","IBM Plex Mono",ui-monospace,Menlo,monospace;}
body{min-height:100vh;padding:18px clamp(12px,3vw,32px);
background-image:
repeating-linear-gradient(0deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px),
repeating-linear-gradient(90deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px);}
header{display:flex;align-items:flex-end;justify-content:space-between;
border-bottom:2px solid var(--rule);padding-bottom:8px;margin-bottom:14px;flex-wrap:wrap;gap:8px;}
header h1{margin:0;font-size:clamp(20px,3.4vw,30px);letter-spacing:.04em;
text-transform:uppercase;font-weight:800;}
header h1 .dot{color:var(--accent)}
header .meta{font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:.12em}
#status{font-weight:700;color:var(--ink)}
#status.bad{color:var(--accent)}
main{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:18px;align-items:start;}
@media (max-width:780px){main{grid-template-columns:1fr}}
.stage{background:#fff;border:2px solid var(--rule);box-shadow:6px 6px 0 var(--rule);
padding:14px;position:relative;overflow:hidden;}
.stage::before{content:"250 \\00D7 122 \\00B7 MONO";position:absolute;top:6px;right:10px;
font-size:10px;letter-spacing:.18em;color:var(--mute);}
.canvas-wrap{display:flex;justify-content:center;align-items:center;padding:8px 0 4px;}
#view{image-rendering:pixelated;image-rendering:crisp-edges;
width:min(100%, 750px);aspect-ratio:250 / 122;background:#fff;
border:1px solid var(--rule);cursor:crosshair;touch-action:none;display:block;}
.ruler{margin-top:8px;display:flex;justify-content:space-between;
font-size:10px;color:var(--mute);letter-spacing:.18em;}
aside{border:2px solid var(--rule);background:#fff;box-shadow:6px 6px 0 var(--rule);
padding:12px;display:flex;flex-direction:column;gap:14px;}
.group{display:flex;flex-direction:column;gap:6px}
.label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);
border-bottom:1px dashed var(--rule);padding-bottom:3px;margin-bottom:2px;}
.row{display:flex;flex-wrap:wrap;gap:6px}
button{appearance:none;border:1.5px solid var(--rule);background:var(--paper);
color:var(--ink);font-family:inherit;font-size:12px;padding:7px 9px;cursor:pointer;
letter-spacing:.04em;text-transform:uppercase;font-weight:700;
transition:transform .04s ease, background .1s ease;}
button:hover{background:#fff}
button:active{transform:translate(1px,1px)}
button.on{background:var(--ink);color:var(--paper)}
button.send{background:var(--accent);color:#fff;border-color:var(--accent);
box-shadow:3px 3px 0 var(--rule);}
button.send:hover{background:#e62e23}
button.danger{border-color:var(--accent);color:var(--accent)}
input[type=range]{width:100%}
.size-readout{font-size:11px;color:var(--mute);text-align:right}
.swatches{display:flex;gap:6px}
.sw{width:32px;height:24px;border:1.5px solid var(--rule);cursor:pointer;
display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;}
.sw.black{background:#000;color:#fff}
.sw.white{background:#fff;color:#000}
.sw.on{outline:3px solid var(--accent);outline-offset:1px}
footer{margin-top:14px;border-top:2px solid var(--rule);padding-top:6px;
display:flex;justify-content:space-between;font-size:10px;color:var(--mute);
letter-spacing:.16em;text-transform:uppercase;flex-wrap:wrap;gap:6px;}
kbd{font-family:inherit;border:1px solid var(--rule);padding:1px 4px;
background:var(--paper);font-size:10px;}
</style>
</head>
<body>
<header>
<h1>PICO E-INK CANVAS<span class="dot">.</span></h1>
<div class="meta">STATUS: <span id="status">ready</span></div>
</header>
<main>
<section class="stage">
<div id="dbg" style="background:#fff7d6;border:1px solid #c8b663;padding:4px 8px;font-size:11px;margin-bottom:8px;font-family:ui-monospace,monospace;color:#000">init...</div>
<div class="canvas-wrap">
<canvas id="view" width="250" height="122"></canvas>
</div>
<div class="ruler"><span>0</span><span>125</span><span>250 PX</span></div>
</section>
<aside>
<div class="group">
<div class="label">Tool</div>
<div class="row" id="tools">
<button data-tool="brush" class="on">Brush</button>
<button data-tool="eraser">Eraser</button>
<button data-tool="line">Line</button>
<button data-tool="rect">Rect</button>
<button data-tool="circle">Circle</button>
<button data-tool="fill">Fill</button>
</div>
</div>
<div class="group">
<div class="label">Ink</div>
<div class="swatches" id="swatches">
<div class="sw black on" data-ink="0">B</div>
<div class="sw white" data-ink="1">W</div>
</div>
</div>
<div class="group">
<div class="label">Brush size</div>
<input id="size" type="range" min="1" max="16" value="2">
<div class="size-readout"><span id="sizeOut">2</span> px</div>
</div>
<div class="group">
<div class="label">Waves & flow</div>
<div class="row">
<button data-gen="scribble">Scribble</button>
<button data-gen="sinewave">Sine</button>
<button data-gen="interference">Ripples</button>
<button data-gen="rose">Rose</button>
<button data-gen="lissajous">Lissajous</button>
<button data-gen="flow">Flow field</button>
</div>
</div>
<div class="group">
<div class="label">Symmetry & tiling</div>
<div class="row">
<button data-gen="truchet">Truchet</button>
<button data-gen="kaleido">Kaleido</button>
<button data-gen="hex">Hex grid</button>
<button data-gen="herringbone">Herring</button>
<button data-gen="weave">Weave</button>
<button data-gen="moire">Moir\u00e9</button>
</div>
</div>
<div class="group">
<div class="label">Fractals</div>
<div class="row">
<button data-gen="sierpinski">Sierpinski</button>
<button data-gen="carpet">Carpet</button>
<button data-gen="dragon">Dragon</button>
<button data-gen="koch">Koch</button>
<button data-gen="hilbert">Hilbert</button>
<button data-gen="mandelbrot">Mandel</button>
</div>
</div>
<div class="group">
<div class="label">Geometric</div>
<div class="row">
<button data-gen="grad">Gradient</button>
<button data-gen="circles">Circles</button>
<button data-gen="sunburst">Sunburst</button>
<button data-gen="parabolic">Stitching</button>
<button data-gen="mystic">Mystic rose</button>
<button data-gen="spirograph">Spiro</button>
</div>
</div>
<div class="group">
<div class="label">Cellular & random</div>
<div class="row">
<button data-gen="stars">Stars</button>
<button data-gen="noise">Noise</button>
<button data-gen="maze">Maze</button>
<button data-gen="life">Life</button>
<button data-gen="voronoi">Voronoi</button>
<button data-gen="dla">DLA tree</button>
</div>
</div>
<div class="group">
<div class="label">Canvas</div>
<div class="row">
<button id="btn-undo">Undo</button>
<button id="btn-clear" class="danger">Clear</button>
<button id="btn-invert">Invert</button>
<button id="btn-png">Save PNG</button>
</div>
</div>
<div class="group">
<div class="label">Send to e-ink</div>
<div class="row">
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
</div>
<div class="row">
<button id="btn-wipe" class="danger">Blank screen</button>
</div>
</div>
</aside>
</main>
<footer>
<span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
<span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
const d = document.getElementById('dbg');
if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});
(function(){
const W = 250, H = 122;
const view = document.getElementById('view');
const dbg = document.getElementById('dbg');
const setDbg = m => { if(dbg) dbg.textContent = m; };
if(!view){ setDbg('FATAL: canvas element missing'); return; }
const ctx = view.getContext('2d');
if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }
// pixel buffer: 1 byte per pixel, 0=black 1=white
let pixels = new Uint8Array(W*H);
pixels.fill(1);
// --- repaint via ImageData ---
const imgd = ctx.createImageData(W, H);
function repaint(){
const d = imgd.data;
for(let i=0;i<pixels.length;i++){
const v = pixels[i] ? 255 : 0;
const j = i*4;
d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
}
ctx.putImageData(imgd, 0, 0);
}
repaint();
setDbg('ready - tap or drag the canvas');
// --- drawing ops in pixel space ---
function setPx(x,y,ink){
if(x<0||y<0||x>=W||y>=H) return;
pixels[y*W + x] = ink;
}
function stamp(cx,cy,r,ink){
cx|=0; cy|=0;
if(r<=1){ setPx(cx,cy,ink); return; }
const h = Math.max(1, r/2)|0;
const r2 = h*h;
for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
const dx=x-cx, dy=y-cy;
if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
}
}
}
function drawLine(x0,y0,x1,y1,r,ink){
x0|=0; y0|=0; x1|=0; y1|=0;
const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
let err=dx+dy, guard=0;
while(guard++<10000){
stamp(x0,y0,r,ink);
if(x0===x1 && y0===y1) break;
const e2=2*err;
if(e2>=dy){ err+=dy; x0+=sx; }
if(e2<=dx){ err+=dx; y0+=sy; }
}
}
function drawRect(x0,y0,x1,y1,r,ink){
drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
}
function drawCircle(cx,cy,rad,r,ink){
if(rad<1){ stamp(cx,cy,r,ink); return; }
let x=rad,y=0,err=0,guard=0;
while(x>=y && guard++<5000){
stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
y++; err+=1+2*y;
if(2*(err-x)+1>0){ x--; err+=1-2*x; }
}
}
function floodFill(sx,sy,ink){
const target=pixels[sy*W+sx];
if(target===undefined || target===ink) return;
const stack=[sx,sy];
while(stack.length){
const y=stack.pop(), x=stack.pop();
if(x<0||y<0||x>=W||y>=H) continue;
if(pixels[y*W+x]!==target) continue;
pixels[y*W+x]=ink;
stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
}
}
// --- state ---
let tool='brush', ink=0, size=3;
let drawing=false, last=null, startPt=null, snap=null;
const undoStack=[];
function pushUndo(){
undoStack.push(new Uint8Array(pixels));
if(undoStack.length>20) undoStack.shift();
}
// --- pointer math: get pixel coords from any event ---
function getXY(clientX, clientY){
const r = view.getBoundingClientRect();
let x = ((clientX - r.left) / r.width) * W;
let y = ((clientY - r.top) / r.height) * H;
x = Math.max(0, Math.min(W-1, x|0));
y = Math.max(0, Math.min(H-1, y|0));
return [x,y];
}
function startStroke(clientX, clientY){
pushUndo();
drawing = true;
const [x,y] = getXY(clientX, clientY);
startPt = [x,y]; last = [x,y];
setDbg('down @ '+x+','+y+' tool='+tool);
if(tool==='brush' || tool==='eraser'){
stamp(x, y, size, tool==='eraser'?1:ink);
repaint();
} else if(tool==='fill'){
floodFill(x, y, ink);
repaint();
drawing = false;
} else {
snap = new Uint8Array(pixels);
}
}
function moveStroke(clientX, clientY){
if(!drawing) return;
const [x,y] = getXY(clientX, clientY);
if(tool==='brush' || tool==='eraser'){
drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
last = [x,y];
repaint();
setDbg('draw @ '+x+','+y);
} else if(tool==='line' || tool==='rect' || tool==='circle'){
pixels.set(snap);
if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
else {
const dx=x-startPt[0], dy=y-startPt[1];
drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
}
repaint();
}
}
function endStroke(){
if(!drawing) return;
drawing=false; last=null; startPt=null; snap=null;
setDbg('stroke ended');
}
// --- attach pointer events with both pointer + mouse + touch fallbacks ---
if('PointerEvent' in window){
view.addEventListener('pointerdown', e => {
e.preventDefault();
try{ view.setPointerCapture(e.pointerId); }catch(_){}
startStroke(e.clientX, e.clientY);
});
view.addEventListener('pointermove', e => {
if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
});
window.addEventListener('pointerup', endStroke);
window.addEventListener('pointercancel', endStroke);
setDbg('using PointerEvent');
} else {
view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
window.addEventListener('mouseup', endStroke);
view.addEventListener('touchstart', e => {
if(!e.touches[0]) return;
e.preventDefault();
startStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchmove', e => {
if(!e.touches[0]) return;
e.preventDefault();
moveStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchend', endStroke);
view.addEventListener('touchcancel', endStroke);
setDbg('using mouse+touch fallback');
}
// --- toolbar ---
document.getElementById('tools').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
tool = b.dataset.tool;
document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
setDbg('tool: '+tool);
});
document.getElementById('swatches').addEventListener('click', e => {
const s = e.target.closest('.sw'); if(!s) return;
ink = parseInt(s.dataset.ink, 10);
document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
setDbg('ink: '+(ink?'white':'black'));
});
const sizeIn = document.getElementById('size');
const sizeOut = document.getElementById('sizeOut');
size = +sizeIn.value || 3;
sizeOut.textContent = size;
sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });
document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
document.getElementById('btn-invert').addEventListener('click', () => {
pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
});
document.getElementById('btn-undo').addEventListener('click', () => {
if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
});
document.getElementById('btn-png').addEventListener('click', () => {
const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
oc.drawImage(view, 0, 0, off.width, off.height);
off.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'pico-canvas.png'; a.click();
});
});
// --- generators ---
// --- generators (math / art patterns) ---
// Helpers used by several gens
function plotBresenham(x0,y0,x1,y1,ink){
drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
}
function fillRectPx(x0,y0,x1,y1,ink){
if(x0>x1){ const t=x0;x0=x1;x1=t; }
if(y0>y1){ const t=y0;y0=y1;y1=t; }
x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
}
const gens = {
scribble(){
pixels.fill(1); let x=W/2, y=H/2;
for(let i=0;i<600;i++){
const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
}
},
sinewave(){
pixels.fill(1);
const layers = 5;
for(let L=0; L<layers; L++){
const amp = 8 + L*4;
const freq = 0.05 + L*0.015;
const phase = L*0.7;
const yc = H/2 + (L-layers/2)*4;
let py = (yc + Math.sin(phase)*amp)|0;
for(let x=0; x<W; x++){
const y = (yc + Math.sin(x*freq + phase)*amp)|0;
drawLine(x-1, py, x, y, 1, 0);
py = y;
}
}
},
interference(){
// Two wave sources; threshold the sum to make moire/ripple bands
pixels.fill(1);
const s1x=W*0.30, s1y=H*0.5;
const s2x=W*0.70, s2y=H*0.5;
const k = 0.55; // wave number
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
const v = Math.cos(d1*k) + Math.cos(d2*k);
pixels[y*W+x] = v>0 ? 0 : 1;
}
}
},
rose(){
// Rose curve r = a*cos(k*theta), k = n/d
pixels.fill(1);
const cx=W/2, cy=H/2;
const a = Math.min(W,H)*0.45;
const n = 5, d = 1; // 5-petal rose; tweak for variety
const steps = 4000;
let px=null, py=null;
for(let i=0; i<=steps; i++){
const th = (i/steps) * Math.PI * 2 * d;
const r = a * Math.cos(n/d * th);
const x = (cx + r*Math.cos(th))|0;
const y = (cy + r*Math.sin(th))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
lissajous(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const A=W*0.46, B=H*0.46;
const a=3, b=2, delta=Math.PI/2;
const steps=3000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2;
const x = (cx + A*Math.sin(a*t + delta))|0;
const y = (cy + B*Math.sin(b*t))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
flow(){
// Pseudo-Perlin flow field via cheap value-noise hash
pixels.fill(1);
function hash(x,y){
let h = (x*374761393 + y*668265263) | 0;
h = (h ^ (h>>>13)) * 1274126177 | 0;
return ((h ^ (h>>>16)) >>> 0) / 4294967295;
}
function noise(x,y){
const xi=Math.floor(x), yi=Math.floor(y);
const xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const n00=hash(xi,yi), n10=hash(xi+1,yi);
const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
}
const seeds = 80, steps = 60;
for(let s=0; s<seeds; s++){
let x = Math.random()*W, y = Math.random()*H;
for(let i=0;i<steps;i++){
const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
const nx = x + Math.cos(ang)*1.4;
const ny = y + Math.sin(ang)*1.4;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
if(x<0||x>=W||y<0||y>=H) break;
}
}
},
truchet(){
pixels.fill(1); const t=12;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
else drawLine(x+t,y,x,y+t,1,0);
}
},
kaleido(){
// Draw random strokes in one wedge, then mirror across N-fold symmetry
pixels.fill(1);
const cx=W/2, cy=H/2;
const N = 6;
const segs = 30;
const pts = [];
for(let i=0;i<segs;i++){
const r1 = Math.random()*Math.min(W,H)*0.45;
const r2 = Math.random()*Math.min(W,H)*0.45;
const a1 = Math.random()*Math.PI*2/N;
const a2 = a1 + (Math.random()-.5)*0.6;
pts.push([r1,a1,r2,a2]);
}
for(let k=0;k<N;k++){
const base = k*Math.PI*2/N;
for(const [r1,a1,r2,a2] of pts){
const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
// mirror within wedge
const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
}
}
},
hex(){
pixels.fill(1);
const r = 8; // hex circumradius
const dx = r*Math.sqrt(3);
const dy = r*1.5;
for(let row=-1; row*dy<H+r; row++){
for(let col=-1; col*dx<W+r; col++){
const cx = col*dx + (row&1?dx/2:0);
const cy = row*dy;
// hex outline
let px=null, py=null;
for(let k=0;k<=6;k++){
const a = Math.PI/3*k - Math.PI/2;
const x = (cx+Math.cos(a)*r)|0;
const y = (cy+Math.sin(a)*r)|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
}
}
},
herringbone(){
pixels.fill(1);
const bw=18, bh=6;
for(let y=0;y<H+bh;y+=bh){
for(let x=-bw;x<W;x+=bw){
const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
// diagonal brick
drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
}
}
},
weave(){
pixels.fill(1);
const t=6;
// horizontal stripes
for(let y=0;y<H;y+=t*2){
for(let x=0;x<W;x+=t*2){
fillRectPx(x, y, x+t-1, y+t-1, 0);
fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
}
}
},
moire(){
// Two rotated line gratings
pixels.fill(1);
const a1 = 0.1, a2 = -0.13;
const sp = 4;
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const u = x*Math.cos(a1)+y*Math.sin(a1);
const v = x*Math.cos(a2)+y*Math.sin(a2);
const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
if(on) pixels[y*W+x]=0;
}
}
},
sierpinski(){
pixels.fill(1);
const ax=W/2, ay=4;
const bx=4, by=H-4;
const cx=W-4, cy=H-4;
let px=W/2, py=H/2;
const verts=[[ax,ay],[bx,by],[cx,cy]];
// Chaos game
for(let i=0;i<8000;i++){
const v = verts[(Math.random()*3)|0];
px = (px+v[0])/2;
py = (py+v[1])/2;
if(i>20) setPx(px|0, py|0, 0);
}
},
carpet(){
// Sierpinski carpet by recursive subdivision
pixels.fill(0); // start black, carve white holes
function carve(x,y,w,h,depth){
if(depth===0 || w<3 || h<3) return;
const w3=w/3, h3=h/3;
fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
if(ix===1 && iy===1) continue;
carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
}
}
carve(0,0,W,H,4);
},
dragon(){
pixels.fill(1);
let path = [1];
for(let i=0;i<11;i++){
const rev = [];
for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
path = path.concat([1]).concat(rev);
}
// walk
let x=W*0.35, y=H*0.55, dir=0;
const step=2;
for(const turn of path){
const nx = x + Math.cos(dir)*step;
const ny = y + Math.sin(dir)*step;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
dir += turn ? Math.PI/2 : -Math.PI/2;
}
},
koch(){
pixels.fill(1);
// Koch snowflake from equilateral triangle, 4 iterations
function koch(p1,p2,depth){
if(depth===0){
drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
return;
}
const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
const a=[p1[0]+dx, p1[1]+dy];
const b=[p1[0]+2*dx, p1[1]+2*dy];
const ang = Math.atan2(dy,dx) - Math.PI/3;
const len = Math.sqrt(dx*dx+dy*dy);
const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
koch(p1,a,depth-1); koch(a,peak,depth-1);
koch(peak,b,depth-1); koch(b,p2,depth-1);
}
const cx=W/2, cy=H/2;
const r=Math.min(W,H)*0.42;
const v=[];
for(let i=0;i<3;i++){
const a = -Math.PI/2 + i*Math.PI*2/3;
v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
},
hilbert(){
pixels.fill(1);
// Iterative Hilbert curve fitting in min(W,H) square
const order = 5; // 2^5 = 32 cells per side
const N = 1<<order;
const cell = Math.min(W,H)/N;
const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
function d2xy(d){
let x=0,y=0,t=d;
for(let s=1;s<N;s<<=1){
const rx = 1 & (t>>1);
const ry = 1 & (t ^ rx);
if(ry===0){
if(rx===1){ x=s-1-x; y=s-1-y; }
const tmp=x; x=y; y=tmp;
}
x += s*rx; y += s*ry;
t >>= 2;
}
return [x,y];
}
let prev=null;
for(let i=0;i<N*N;i++){
const [gx,gy]=d2xy(i);
const x = (ox + gx*cell + cell/2)|0;
const y = (oy + gy*cell + cell/2)|0;
if(prev) drawLine(prev[0],prev[1],x,y,1,0);
prev=[x,y];
}
},
mandelbrot(){
pixels.fill(1);
const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
const maxIter=24;
for(let py=0;py<H;py++){
const cy = ymin + (ymax-ymin)*py/H;
for(let px=0;px<W;px++){
const cx = xmin + (xmax-xmin)*px/W;
let zx=0, zy=0, i=0;
while(i<maxIter && zx*zx+zy*zy<4){
const t = zx*zx-zy*zy+cx;
zy = 2*zx*zy+cy;
zx = t;
i++;
}
// Threshold dither: high-iter -> dark, low-iter -> light
const v = i/maxIter;
const bayer = ((px+py*7)*0.137)%1;
pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
if(i===maxIter) pixels[py*W+px]=0;
}
}
},
grad(){
const bayer=[
[0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
[12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
[3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
[15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
}
},
circles(){
pixels.fill(1);
const cx=W/2, cy=H/2;
for(let r=4; r<Math.max(W,H); r+=6){
drawCircle(cx,cy,r,1,0);
}
},
sunburst(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const rays = 64;
const R = Math.max(W,H);
for(let i=0;i<rays;i++){
const a = i*Math.PI*2/rays;
const x = cx+Math.cos(a)*R;
const y = cy+Math.sin(a)*R;
drawLine(cx|0,cy|0,x|0,y|0,1,0);
}
},
parabolic(){
// Curve stitching: connect points across two perpendicular axes
pixels.fill(1);
const N = 24;
const x0=4, y0=4, x1=W-4, y1=H-4;
// top-left corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
}
// bottom-right corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
}
},
mystic(){
// Mystic rose: N points on a circle, every chord drawn
pixels.fill(1);
const cx=W/2, cy=H/2;
const r = Math.min(W,H)*0.45;
const N = 18;
const pts = [];
for(let i=0;i<N;i++){
const a = i*Math.PI*2/N - Math.PI/2;
pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
}
},
spirograph(){
// Hypotrochoid: outer R, inner r, pen offset d
pixels.fill(1);
const cx=W/2, cy=H/2;
const R = Math.min(W,H)*0.42;
const r = R*0.31;
const d = R*0.55;
const steps = 4000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2*7;
const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
px=x; py=y;
}
},
stars(){
pixels.fill(0);
for(let i=0;i<180;i++){
stamp((Math.random()*W)|0, (Math.random()*H)|0,
Math.random()<.15?2:1, 1);
}
},
noise(){
for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
},
maze(){
pixels.fill(1); const t=8;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
else drawLine(x,y,x,y+t,1,0);
}
drawRect(0,0,W-1,H-1,1,0);
},
life(){
// Run Conway's Game of Life from random soup, snapshot at step N
let cur = new Uint8Array(W*H);
for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
let nxt = new Uint8Array(W*H);
for(let gen=0; gen<30; gen++){
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let n=0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
if(dx===0&&dy===0) continue;
const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
n += cur[yy*W+xx];
}
const c = cur[y*W+x];
nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
}
}
const t=cur; cur=nxt; nxt=t;
}
// alive=black on white paper
for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
},
voronoi(){
// Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
const N = 22;
const sx=[], sy=[];
for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
const owner = new Int16Array(W*H);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let best=0, bd=1e9;
for(let i=0;i<N;i++){
const dx=x-sx[i], dy=y-sy[i];
const d=dx*dx+dy*dy;
if(d<bd){ bd=d; best=i; }
}
owner[y*W+x] = best;
}
}
pixels.fill(1);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const o = owner[y*W+x];
if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
}
}
},
dla(){
// Diffusion-limited aggregation: random walkers stick to growing tree
pixels.fill(1);
const grid = new Uint8Array(W*H);
const cx=W/2|0, cy=H/2|0;
grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
const maxParticles = 800;
for(let p=0;p<maxParticles;p++){
// spawn on a circle of radius rmax
let ang = Math.random()*Math.PI*2;
let rad = Math.min(W,H)*0.45;
let x = cx + (Math.cos(ang)*rad)|0;
let y = cy + (Math.sin(ang)*rad)|0;
for(let step=0; step<2000; step++){
x += (Math.random()<.5?-1:1);
y += (Math.random()<.5?-1:1);
if(x<1||x>=W-1||y<1||y>=H-1){ break; }
// touch?
if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
grid[y*W+x-1]||grid[y*W+x+1]){
grid[y*W+x]=1; pixels[y*W+x]=0; break;
}
}
}
},
};
document.querySelectorAll('[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.gen;
const fn = gens[k];
if(!fn){ setDbg('unknown gen: '+k); return; }
pushUndo();
try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
repaint();
setDbg('generated: '+k);
});
});
// --- network ---
const statusEl = document.getElementById('status');
function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }
function packBitmap(){
const stride = (W+7)>>3;
const out = new Uint8Array(stride * H);
for(let y=0;y<H;y++){
for(let xb=0;xb<stride;xb++){
let b = 0;
for(let bit=0;bit<8;bit++){
const x = xb*8 + bit;
const v = (x<W) ? pixels[y*W+x] : 1;
if(v) b |= (1 << (7-bit));
}
out[y*stride + xb] = b;
}
}
return out;
}
async function sendDraw(mode){
setStatus('sending...');
try{
const r = await fetch('/draw?mode='+mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
if(!r.ok) throw new Error('HTTP '+r.status);
const j = await r.json();
setStatus('sent - '+j.mode+' - '+j.ms+'ms');
} catch(err){ setStatus('send failed: '+err.message, true); }
}
async function sendClear(){
setStatus('clearing...');
try{
const r = await fetch('/clear', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
setStatus('blanked');
} catch(err){ setStatus('clear failed: '+err.message, true); }
}
document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
document.getElementById('btn-wipe').addEventListener('click', sendClear);
fetch('/status').then(r => r.json()).then(j => {
setStatus('connected '+j.w+'x'+j.h);
}).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
Code In Chunks (Human Explanation)
Chunk 1: UI skeleton
This is the minimal interface surface. I intentionally kept it simple so I could spend more time on the hardware communication part.
<canvas id="view" width="250" height="122"></canvas>
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
Chunk 2: Font and look choice
I used a mono-style technical font stack because it fits the tool-like style and stays readable while debugging.
font-family: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
Chunk 3: Pixel packing for e-ink
The browser canvas is pixel-based, but the display expects 1-bit packed bytes. This conversion step was one of the key technical points of the week.
function packBitmap(){
const stride = (W + 7) >> 3;
const out = new Uint8Array(stride * H);
for (let y = 0; y < H; y++) {
for (let xb = 0; xb < stride; xb++) {
let b = 0;
for (let bit = 0; bit < 8; bit++) {
const x = xb * 8 + bit;
const v = (x < W) ? pixels[y * W + x] : 1;
if (v) b |= (1 << (7 - bit));
}
out[y * stride + xb] = b;
}
}
return out;
}
Chunk 4: Sending bitmap data
This sends binary data to Pico W and lets me choose partial or full refresh from the UI.
async function sendDraw(mode){
const r = await fetch('/draw?mode=' + mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
const j = await r.json();
console.log('sent', j.mode, j.ms + 'ms');
}
Chunk 5: Wi-Fi bring-up on Pico W
The board connects to local Wi-Fi and returns an IP address so I can open the drawing interface from any device on the same network.
def _wifi_connect(ssid, password, timeout=60):
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
wlan.connect(ssid, password)
return wlan.ifconfig()[0]
Chunk 6: Server routing
Core endpoints:
/statusfor device info/drawfor bitmap payload/clearfor wiping display
if method == "GET" and path == "/status":
_send(conn, "200 OK", '{"ok":true}', content_type="application/json")
elif method == "POST" and path == "/clear":
epd.clear()
elif method == "POST" and path == "/draw":
body = _read_exact(conn, n, leftover)
_blit_bitmap_to_fb(epd, body)
Chunk 7: Partial vs full refresh policy
Partial refresh is faster for interaction. Periodic full refresh is necessary to reduce ghosting on e-ink.
mode = "partial"
if "mode=full" in query:
mode = "full"
full_refresh_counter += 1
if full_refresh_counter >= 12:
mode = "full"
full_refresh_counter = 0
All-in-one Pico server (canvas_server.py)
The assignment stack above is also bundled as a single MicroPython file you can drop on the Pico beside epd2in13.py: it embeds the full browser UI (drawing tools, procedural generators, packBitmap(), fetch to /draw / /clear / /status) and implements the socket HTTP server, Wi-Fi bring-up, chunked send for large responses, bitmap unpack into the EPD buffer, and partial vs full refresh (including automatic full every 12 partial updates). wifi_secrets.py on the device must define SSID and PASSWORD; the published download does not ship credentials.
Run from Thonny (Run) or execute as __main__; on stacks that import the module without __main__, the file still invokes run() so the server starts the same way as your other fab sketches.
Download: canvas_server.py
Full canvas_server.py source
"""
canvas_server.py -- All-in-one Pico W e-ink canvas server.
Files needed on the Pico:
epd2in13.py - your EPD driver
canvas_server.py - this file
Just run this file (Thonny green play, or `import canvas_server` in REPL).
The bottom of the file calls run() automatically.
"""
import gc
import socket
import time
import network
from epd2in13 import EPD, EPD_WIDTH, EPD_HEIGHT
try:
from wifi_secrets import SSID, PASSWORD
except ImportError:
SSID = ""
PASSWORD = ""
HTTP_PORT = 80
EXPECTED_BYTES = ((EPD_WIDTH + 7) // 8) * EPD_HEIGHT # 32 * 122 = 3904
# =============================================================================
# Embedded web app
# =============================================================================
HTML_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>PICO E-INK CANVAS</title>
<style>
:root{--ink:#111;--paper:#f4f1ea;--rule:#111;--accent:#ff3b30;--mute:#7a7268;}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);
font-family:"JetBrains Mono","IBM Plex Mono",ui-monospace,Menlo,monospace;}
body{min-height:100vh;padding:18px clamp(12px,3vw,32px);
background-image:
repeating-linear-gradient(0deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px),
repeating-linear-gradient(90deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px);}
header{display:flex;align-items:flex-end;justify-content:space-between;
border-bottom:2px solid var(--rule);padding-bottom:8px;margin-bottom:14px;flex-wrap:wrap;gap:8px;}
header h1{margin:0;font-size:clamp(20px,3.4vw,30px);letter-spacing:.04em;
text-transform:uppercase;font-weight:800;}
header h1 .dot{color:var(--accent)}
header .meta{font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:.12em}
#status{font-weight:700;color:var(--ink)}
#status.bad{color:var(--accent)}
main{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:18px;align-items:start;}
@media (max-width:780px){main{grid-template-columns:1fr}}
.stage{background:#fff;border:2px solid var(--rule);box-shadow:6px 6px 0 var(--rule);
padding:14px;position:relative;overflow:hidden;}
.stage::before{content:"250 \\00D7 122 \\00B7 MONO";position:absolute;top:6px;right:10px;
font-size:10px;letter-spacing:.18em;color:var(--mute);}
.canvas-wrap{display:flex;justify-content:center;align-items:center;padding:8px 0 4px;}
#view{image-rendering:pixelated;image-rendering:crisp-edges;
width:min(100%, 750px);aspect-ratio:250 / 122;background:#fff;
border:1px solid var(--rule);cursor:crosshair;touch-action:none;display:block;}
.ruler{margin-top:8px;display:flex;justify-content:space-between;
font-size:10px;color:var(--mute);letter-spacing:.18em;}
aside{border:2px solid var(--rule);background:#fff;box-shadow:6px 6px 0 var(--rule);
padding:12px;display:flex;flex-direction:column;gap:14px;}
.group{display:flex;flex-direction:column;gap:6px}
.label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);
border-bottom:1px dashed var(--rule);padding-bottom:3px;margin-bottom:2px;}
.row{display:flex;flex-wrap:wrap;gap:6px}
button{appearance:none;border:1.5px solid var(--rule);background:var(--paper);
color:var(--ink);font-family:inherit;font-size:12px;padding:7px 9px;cursor:pointer;
letter-spacing:.04em;text-transform:uppercase;font-weight:700;
transition:transform .04s ease, background .1s ease;}
button:hover{background:#fff}
button:active{transform:translate(1px,1px)}
button.on{background:var(--ink);color:var(--paper)}
button.send{background:var(--accent);color:#fff;border-color:var(--accent);
box-shadow:3px 3px 0 var(--rule);}
button.send:hover{background:#e62e23}
button.danger{border-color:var(--accent);color:var(--accent)}
input[type=range]{width:100%}
.size-readout{font-size:11px;color:var(--mute);text-align:right}
.swatches{display:flex;gap:6px}
.sw{width:32px;height:24px;border:1.5px solid var(--rule);cursor:pointer;
display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;}
.sw.black{background:#000;color:#fff}
.sw.white{background:#fff;color:#000}
.sw.on{outline:3px solid var(--accent);outline-offset:1px}
footer{margin-top:14px;border-top:2px solid var(--rule);padding-top:6px;
display:flex;justify-content:space-between;font-size:10px;color:var(--mute);
letter-spacing:.16em;text-transform:uppercase;flex-wrap:wrap;gap:6px;}
kbd{font-family:inherit;border:1px solid var(--rule);padding:1px 4px;
background:var(--paper);font-size:10px;}
</style>
</head>
<body>
<header>
<h1>PICO E-INK CANVAS<span class="dot">.</span></h1>
<div class="meta">STATUS: <span id="status">ready</span></div>
</header>
<main>
<section class="stage">
<div id="dbg" style="background:#fff7d6;border:1px solid #c8b663;padding:4px 8px;font-size:11px;margin-bottom:8px;font-family:ui-monospace,monospace;color:#000">init...</div>
<div class="canvas-wrap">
<canvas id="view" width="250" height="122"></canvas>
</div>
<div class="ruler"><span>0</span><span>125</span><span>250 PX</span></div>
</section>
<aside>
<div class="group">
<div class="label">Tool</div>
<div class="row" id="tools">
<button data-tool="brush" class="on">Brush</button>
<button data-tool="eraser">Eraser</button>
<button data-tool="line">Line</button>
<button data-tool="rect">Rect</button>
<button data-tool="circle">Circle</button>
<button data-tool="fill">Fill</button>
</div>
</div>
<div class="group">
<div class="label">Ink</div>
<div class="swatches" id="swatches">
<div class="sw black on" data-ink="0">B</div>
<div class="sw white" data-ink="1">W</div>
</div>
</div>
<div class="group">
<div class="label">Brush size</div>
<input id="size" type="range" min="1" max="16" value="2">
<div class="size-readout"><span id="sizeOut">2</span> px</div>
</div>
<div class="group">
<div class="label">Waves & flow</div>
<div class="row">
<button data-gen="scribble">Scribble</button>
<button data-gen="sinewave">Sine</button>
<button data-gen="interference">Ripples</button>
<button data-gen="rose">Rose</button>
<button data-gen="lissajous">Lissajous</button>
<button data-gen="flow">Flow field</button>
</div>
</div>
<div class="group">
<div class="label">Symmetry & tiling</div>
<div class="row">
<button data-gen="truchet">Truchet</button>
<button data-gen="kaleido">Kaleido</button>
<button data-gen="hex">Hex grid</button>
<button data-gen="herringbone">Herring</button>
<button data-gen="weave">Weave</button>
<button data-gen="moire">Moir\u00e9</button>
</div>
</div>
<div class="group">
<div class="label">Fractals</div>
<div class="row">
<button data-gen="sierpinski">Sierpinski</button>
<button data-gen="carpet">Carpet</button>
<button data-gen="dragon">Dragon</button>
<button data-gen="koch">Koch</button>
<button data-gen="hilbert">Hilbert</button>
<button data-gen="mandelbrot">Mandel</button>
</div>
</div>
<div class="group">
<div class="label">Geometric</div>
<div class="row">
<button data-gen="grad">Gradient</button>
<button data-gen="circles">Circles</button>
<button data-gen="sunburst">Sunburst</button>
<button data-gen="parabolic">Stitching</button>
<button data-gen="mystic">Mystic rose</button>
<button data-gen="spirograph">Spiro</button>
</div>
</div>
<div class="group">
<div class="label">Cellular & random</div>
<div class="row">
<button data-gen="stars">Stars</button>
<button data-gen="noise">Noise</button>
<button data-gen="maze">Maze</button>
<button data-gen="life">Life</button>
<button data-gen="voronoi">Voronoi</button>
<button data-gen="dla">DLA tree</button>
</div>
</div>
<div class="group">
<div class="label">Canvas</div>
<div class="row">
<button id="btn-undo">Undo</button>
<button id="btn-clear" class="danger">Clear</button>
<button id="btn-invert">Invert</button>
<button id="btn-png">Save PNG</button>
</div>
</div>
<div class="group">
<div class="label">Send to e-ink</div>
<div class="row">
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
</div>
<div class="row">
<button id="btn-wipe" class="danger">Blank screen</button>
</div>
</div>
</aside>
</main>
<footer>
<span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
<span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
const d = document.getElementById('dbg');
if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});
(function(){
const W = 250, H = 122;
const view = document.getElementById('view');
const dbg = document.getElementById('dbg');
const setDbg = m => { if(dbg) dbg.textContent = m; };
if(!view){ setDbg('FATAL: canvas element missing'); return; }
const ctx = view.getContext('2d');
if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }
// pixel buffer: 1 byte per pixel, 0=black 1=white
let pixels = new Uint8Array(W*H);
pixels.fill(1);
// --- repaint via ImageData ---
const imgd = ctx.createImageData(W, H);
function repaint(){
const d = imgd.data;
for(let i=0;i<pixels.length;i++){
const v = pixels[i] ? 255 : 0;
const j = i*4;
d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
}
ctx.putImageData(imgd, 0, 0);
}
repaint();
setDbg('ready - tap or drag the canvas');
// --- drawing ops in pixel space ---
function setPx(x,y,ink){
if(x<0||y<0||x>=W||y>=H) return;
pixels[y*W + x] = ink;
}
function stamp(cx,cy,r,ink){
cx|=0; cy|=0;
if(r<=1){ setPx(cx,cy,ink); return; }
const h = Math.max(1, r/2)|0;
const r2 = h*h;
for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
const dx=x-cx, dy=y-cy;
if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
}
}
}
function drawLine(x0,y0,x1,y1,r,ink){
x0|=0; y0|=0; x1|=0; y1|=0;
const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
let err=dx+dy, guard=0;
while(guard++<10000){
stamp(x0,y0,r,ink);
if(x0===x1 && y0===y1) break;
const e2=2*err;
if(e2>=dy){ err+=dy; x0+=sx; }
if(e2<=dx){ err+=dx; y0+=sy; }
}
}
function drawRect(x0,y0,x1,y1,r,ink){
drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
}
function drawCircle(cx,cy,rad,r,ink){
if(rad<1){ stamp(cx,cy,r,ink); return; }
let x=rad,y=0,err=0,guard=0;
while(x>=y && guard++<5000){
stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
y++; err+=1+2*y;
if(2*(err-x)+1>0){ x--; err+=1-2*x; }
}
}
function floodFill(sx,sy,ink){
const target=pixels[sy*W+sx];
if(target===undefined || target===ink) return;
const stack=[sx,sy];
while(stack.length){
const y=stack.pop(), x=stack.pop();
if(x<0||y<0||x>=W||y>=H) continue;
if(pixels[y*W+x]!==target) continue;
pixels[y*W+x]=ink;
stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
}
}
// --- state ---
let tool='brush', ink=0, size=3;
let drawing=false, last=null, startPt=null, snap=null;
const undoStack=[];
function pushUndo(){
undoStack.push(new Uint8Array(pixels));
if(undoStack.length>20) undoStack.shift();
}
// --- pointer math: get pixel coords from any event ---
function getXY(clientX, clientY){
const r = view.getBoundingClientRect();
let x = ((clientX - r.left) / r.width) * W;
let y = ((clientY - r.top) / r.height) * H;
x = Math.max(0, Math.min(W-1, x|0));
y = Math.max(0, Math.min(H-1, y|0));
return [x,y];
}
function startStroke(clientX, clientY){
pushUndo();
drawing = true;
const [x,y] = getXY(clientX, clientY);
startPt = [x,y]; last = [x,y];
setDbg('down @ '+x+','+y+' tool='+tool);
if(tool==='brush' || tool==='eraser'){
stamp(x, y, size, tool==='eraser'?1:ink);
repaint();
} else if(tool==='fill'){
floodFill(x, y, ink);
repaint();
drawing = false;
} else {
snap = new Uint8Array(pixels);
}
}
function moveStroke(clientX, clientY){
if(!drawing) return;
const [x,y] = getXY(clientX, clientY);
if(tool==='brush' || tool==='eraser'){
drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
last = [x,y];
repaint();
setDbg('draw @ '+x+','+y);
} else if(tool==='line' || tool==='rect' || tool==='circle'){
pixels.set(snap);
if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
else {
const dx=x-startPt[0], dy=y-startPt[1];
drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
}
repaint();
}
}
function endStroke(){
if(!drawing) return;
drawing=false; last=null; startPt=null; snap=null;
setDbg('stroke ended');
}
// --- attach pointer events with both pointer + mouse + touch fallbacks ---
if('PointerEvent' in window){
view.addEventListener('pointerdown', e => {
e.preventDefault();
try{ view.setPointerCapture(e.pointerId); }catch(_){}
startStroke(e.clientX, e.clientY);
});
view.addEventListener('pointermove', e => {
if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
});
window.addEventListener('pointerup', endStroke);
window.addEventListener('pointercancel', endStroke);
setDbg('using PointerEvent');
} else {
view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
window.addEventListener('mouseup', endStroke);
view.addEventListener('touchstart', e => {
if(!e.touches[0]) return;
e.preventDefault();
startStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchmove', e => {
if(!e.touches[0]) return;
e.preventDefault();
moveStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchend', endStroke);
view.addEventListener('touchcancel', endStroke);
setDbg('using mouse+touch fallback');
}
// --- toolbar ---
document.getElementById('tools').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
tool = b.dataset.tool;
document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
setDbg('tool: '+tool);
});
document.getElementById('swatches').addEventListener('click', e => {
const s = e.target.closest('.sw'); if(!s) return;
ink = parseInt(s.dataset.ink, 10);
document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
setDbg('ink: '+(ink?'white':'black'));
});
const sizeIn = document.getElementById('size');
const sizeOut = document.getElementById('sizeOut');
size = +sizeIn.value || 3;
sizeOut.textContent = size;
sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });
document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
document.getElementById('btn-invert').addEventListener('click', () => {
pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
});
document.getElementById('btn-undo').addEventListener('click', () => {
if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
});
document.getElementById('btn-png').addEventListener('click', () => {
const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
oc.drawImage(view, 0, 0, off.width, off.height);
off.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'pico-canvas.png'; a.click();
});
});
// --- generators ---
// --- generators (math / art patterns) ---
// Helpers used by several gens
function plotBresenham(x0,y0,x1,y1,ink){
drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
}
function fillRectPx(x0,y0,x1,y1,ink){
if(x0>x1){ const t=x0;x0=x1;x1=t; }
if(y0>y1){ const t=y0;y0=y1;y1=t; }
x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
}
const gens = {
scribble(){
pixels.fill(1); let x=W/2, y=H/2;
for(let i=0;i<600;i++){
const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
}
},
sinewave(){
pixels.fill(1);
const layers = 5;
for(let L=0; L<layers; L++){
const amp = 8 + L*4;
const freq = 0.05 + L*0.015;
const phase = L*0.7;
const yc = H/2 + (L-layers/2)*4;
let py = (yc + Math.sin(phase)*amp)|0;
for(let x=0; x<W; x++){
const y = (yc + Math.sin(x*freq + phase)*amp)|0;
drawLine(x-1, py, x, y, 1, 0);
py = y;
}
}
},
interference(){
// Two wave sources; threshold the sum to make moire/ripple bands
pixels.fill(1);
const s1x=W*0.30, s1y=H*0.5;
const s2x=W*0.70, s2y=H*0.5;
const k = 0.55; // wave number
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
const v = Math.cos(d1*k) + Math.cos(d2*k);
pixels[y*W+x] = v>0 ? 0 : 1;
}
}
},
rose(){
// Rose curve r = a*cos(k*theta), k = n/d
pixels.fill(1);
const cx=W/2, cy=H/2;
const a = Math.min(W,H)*0.45;
const n = 5, d = 1; // 5-petal rose; tweak for variety
const steps = 4000;
let px=null, py=null;
for(let i=0; i<=steps; i++){
const th = (i/steps) * Math.PI * 2 * d;
const r = a * Math.cos(n/d * th);
const x = (cx + r*Math.cos(th))|0;
const y = (cy + r*Math.sin(th))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
lissajous(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const A=W*0.46, B=H*0.46;
const a=3, b=2, delta=Math.PI/2;
const steps=3000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2;
const x = (cx + A*Math.sin(a*t + delta))|0;
const y = (cy + B*Math.sin(b*t))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
flow(){
// Pseudo-Perlin flow field via cheap value-noise hash
pixels.fill(1);
function hash(x,y){
let h = (x*374761393 + y*668265263) | 0;
h = (h ^ (h>>>13)) * 1274126177 | 0;
return ((h ^ (h>>>16)) >>> 0) / 4294967295;
}
function noise(x,y){
const xi=Math.floor(x), yi=Math.floor(y);
const xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const n00=hash(xi,yi), n10=hash(xi+1,yi);
const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
}
const seeds = 80, steps = 60;
for(let s=0; s<seeds; s++){
let x = Math.random()*W, y = Math.random()*H;
for(let i=0;i<steps;i++){
const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
const nx = x + Math.cos(ang)*1.4;
const ny = y + Math.sin(ang)*1.4;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
if(x<0||x>=W||y<0||y>=H) break;
}
}
},
truchet(){
pixels.fill(1); const t=12;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
else drawLine(x+t,y,x,y+t,1,0);
}
},
kaleido(){
// Draw random strokes in one wedge, then mirror across N-fold symmetry
pixels.fill(1);
const cx=W/2, cy=H/2;
const N = 6;
const segs = 30;
const pts = [];
for(let i=0;i<segs;i++){
const r1 = Math.random()*Math.min(W,H)*0.45;
const r2 = Math.random()*Math.min(W,H)*0.45;
const a1 = Math.random()*Math.PI*2/N;
const a2 = a1 + (Math.random()-.5)*0.6;
pts.push([r1,a1,r2,a2]);
}
for(let k=0;k<N;k++){
const base = k*Math.PI*2/N;
for(const [r1,a1,r2,a2] of pts){
const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
// mirror within wedge
const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
}
}
},
hex(){
pixels.fill(1);
const r = 8; // hex circumradius
const dx = r*Math.sqrt(3);
const dy = r*1.5;
for(let row=-1; row*dy<H+r; row++){
for(let col=-1; col*dx<W+r; col++){
const cx = col*dx + (row&1?dx/2:0);
const cy = row*dy;
// hex outline
let px=null, py=null;
for(let k=0;k<=6;k++){
const a = Math.PI/3*k - Math.PI/2;
const x = (cx+Math.cos(a)*r)|0;
const y = (cy+Math.sin(a)*r)|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
}
}
},
herringbone(){
pixels.fill(1);
const bw=18, bh=6;
for(let y=0;y<H+bh;y+=bh){
for(let x=-bw;x<W;x+=bw){
const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
// diagonal brick
drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
}
}
},
weave(){
pixels.fill(1);
const t=6;
// horizontal stripes
for(let y=0;y<H;y+=t*2){
for(let x=0;x<W;x+=t*2){
fillRectPx(x, y, x+t-1, y+t-1, 0);
fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
}
}
},
moire(){
// Two rotated line gratings
pixels.fill(1);
const a1 = 0.1, a2 = -0.13;
const sp = 4;
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const u = x*Math.cos(a1)+y*Math.sin(a1);
const v = x*Math.cos(a2)+y*Math.sin(a2);
const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
if(on) pixels[y*W+x]=0;
}
}
},
sierpinski(){
pixels.fill(1);
const ax=W/2, ay=4;
const bx=4, by=H-4;
const cx=W-4, cy=H-4;
let px=W/2, py=H/2;
const verts=[[ax,ay],[bx,by],[cx,cy]];
// Chaos game
for(let i=0;i<8000;i++){
const v = verts[(Math.random()*3)|0];
px = (px+v[0])/2;
py = (py+v[1])/2;
if(i>20) setPx(px|0, py|0, 0);
}
},
carpet(){
// Sierpinski carpet by recursive subdivision
pixels.fill(0); // start black, carve white holes
function carve(x,y,w,h,depth){
if(depth===0 || w<3 || h<3) return;
const w3=w/3, h3=h/3;
fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
if(ix===1 && iy===1) continue;
carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
}
}
carve(0,0,W,H,4);
},
dragon(){
pixels.fill(1);
let path = [1];
for(let i=0;i<11;i++){
const rev = [];
for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
path = path.concat([1]).concat(rev);
}
// walk
let x=W*0.35, y=H*0.55, dir=0;
const step=2;
for(const turn of path){
const nx = x + Math.cos(dir)*step;
const ny = y + Math.sin(dir)*step;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
dir += turn ? Math.PI/2 : -Math.PI/2;
}
},
koch(){
pixels.fill(1);
// Koch snowflake from equilateral triangle, 4 iterations
function koch(p1,p2,depth){
if(depth===0){
drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
return;
}
const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
const a=[p1[0]+dx, p1[1]+dy];
const b=[p1[0]+2*dx, p1[1]+2*dy];
const ang = Math.atan2(dy,dx) - Math.PI/3;
const len = Math.sqrt(dx*dx+dy*dy);
const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
koch(p1,a,depth-1); koch(a,peak,depth-1);
koch(peak,b,depth-1); koch(b,p2,depth-1);
}
const cx=W/2, cy=H/2;
const r=Math.min(W,H)*0.42;
const v=[];
for(let i=0;i<3;i++){
const a = -Math.PI/2 + i*Math.PI*2/3;
v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
},
hilbert(){
pixels.fill(1);
// Iterative Hilbert curve fitting in min(W,H) square
const order = 5; // 2^5 = 32 cells per side
const N = 1<<order;
const cell = Math.min(W,H)/N;
const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
function d2xy(d){
let x=0,y=0,t=d;
for(let s=1;s<N;s<<=1){
const rx = 1 & (t>>1);
const ry = 1 & (t ^ rx);
if(ry===0){
if(rx===1){ x=s-1-x; y=s-1-y; }
const tmp=x; x=y; y=tmp;
}
x += s*rx; y += s*ry;
t >>= 2;
}
return [x,y];
}
let prev=null;
for(let i=0;i<N*N;i++){
const [gx,gy]=d2xy(i);
const x = (ox + gx*cell + cell/2)|0;
const y = (oy + gy*cell + cell/2)|0;
if(prev) drawLine(prev[0],prev[1],x,y,1,0);
prev=[x,y];
}
},
mandelbrot(){
pixels.fill(1);
const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
const maxIter=24;
for(let py=0;py<H;py++){
const cy = ymin + (ymax-ymin)*py/H;
for(let px=0;px<W;px++){
const cx = xmin + (xmax-xmin)*px/W;
let zx=0, zy=0, i=0;
while(i<maxIter && zx*zx+zy*zy<4){
const t = zx*zx-zy*zy+cx;
zy = 2*zx*zy+cy;
zx = t;
i++;
}
// Threshold dither: high-iter -> dark, low-iter -> light
const v = i/maxIter;
const bayer = ((px+py*7)*0.137)%1;
pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
if(i===maxIter) pixels[py*W+px]=0;
}
}
},
grad(){
const bayer=[
[0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
[12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
[3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
[15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
}
},
circles(){
pixels.fill(1);
const cx=W/2, cy=H/2;
for(let r=4; r<Math.max(W,H); r+=6){
drawCircle(cx,cy,r,1,0);
}
},
sunburst(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const rays = 64;
const R = Math.max(W,H);
for(let i=0;i<rays;i++){
const a = i*Math.PI*2/rays;
const x = cx+Math.cos(a)*R;
const y = cy+Math.sin(a)*R;
drawLine(cx|0,cy|0,x|0,y|0,1,0);
}
},
parabolic(){
// Curve stitching: connect points across two perpendicular axes
pixels.fill(1);
const N = 24;
const x0=4, y0=4, x1=W-4, y1=H-4;
// top-left corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
}
// bottom-right corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
}
},
mystic(){
// Mystic rose: N points on a circle, every chord drawn
pixels.fill(1);
const cx=W/2, cy=H/2;
const r = Math.min(W,H)*0.45;
const N = 18;
const pts = [];
for(let i=0;i<N;i++){
const a = i*Math.PI*2/N - Math.PI/2;
pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
}
},
spirograph(){
// Hypotrochoid: outer R, inner r, pen offset d
pixels.fill(1);
const cx=W/2, cy=H/2;
const R = Math.min(W,H)*0.42;
const r = R*0.31;
const d = R*0.55;
const steps = 4000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2*7;
const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
px=x; py=y;
}
},
stars(){
pixels.fill(0);
for(let i=0;i<180;i++){
stamp((Math.random()*W)|0, (Math.random()*H)|0,
Math.random()<.15?2:1, 1);
}
},
noise(){
for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
},
maze(){
pixels.fill(1); const t=8;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
else drawLine(x,y,x,y+t,1,0);
}
drawRect(0,0,W-1,H-1,1,0);
},
life(){
// Run Conway's Game of Life from random soup, snapshot at step N
let cur = new Uint8Array(W*H);
for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
let nxt = new Uint8Array(W*H);
for(let gen=0; gen<30; gen++){
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let n=0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
if(dx===0&&dy===0) continue;
const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
n += cur[yy*W+xx];
}
const c = cur[y*W+x];
nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
}
}
const t=cur; cur=nxt; nxt=t;
}
// alive=black on white paper
for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
},
voronoi(){
// Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
const N = 22;
const sx=[], sy=[];
for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
const owner = new Int16Array(W*H);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let best=0, bd=1e9;
for(let i=0;i<N;i++){
const dx=x-sx[i], dy=y-sy[i];
const d=dx*dx+dy*dy;
if(d<bd){ bd=d; best=i; }
}
owner[y*W+x] = best;
}
}
pixels.fill(1);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const o = owner[y*W+x];
if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
}
}
},
dla(){
// Diffusion-limited aggregation: random walkers stick to growing tree
pixels.fill(1);
const grid = new Uint8Array(W*H);
const cx=W/2|0, cy=H/2|0;
grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
const maxParticles = 800;
for(let p=0;p<maxParticles;p++){
// spawn on a circle of radius rmax
let ang = Math.random()*Math.PI*2;
let rad = Math.min(W,H)*0.45;
let x = cx + (Math.cos(ang)*rad)|0;
let y = cy + (Math.sin(ang)*rad)|0;
for(let step=0; step<2000; step++){
x += (Math.random()<.5?-1:1);
y += (Math.random()<.5?-1:1);
if(x<1||x>=W-1||y<1||y>=H-1){ break; }
// touch?
if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
grid[y*W+x-1]||grid[y*W+x+1]){
grid[y*W+x]=1; pixels[y*W+x]=0; break;
}
}
}
},
};
document.querySelectorAll('[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.gen;
const fn = gens[k];
if(!fn){ setDbg('unknown gen: '+k); return; }
pushUndo();
try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
repaint();
setDbg('generated: '+k);
});
});
// --- network ---
const statusEl = document.getElementById('status');
function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }
function packBitmap(){
const stride = (W+7)>>3;
const out = new Uint8Array(stride * H);
for(let y=0;y<H;y++){
for(let xb=0;xb<stride;xb++){
let b = 0;
for(let bit=0;bit<8;bit++){
const x = xb*8 + bit;
const v = (x<W) ? pixels[y*W+x] : 1;
if(v) b |= (1 << (7-bit));
}
out[y*stride + xb] = b;
}
}
return out;
}
async function sendDraw(mode){
setStatus('sending...');
try{
const r = await fetch('/draw?mode='+mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
if(!r.ok) throw new Error('HTTP '+r.status);
const j = await r.json();
setStatus('sent - '+j.mode+' - '+j.ms+'ms');
} catch(err){ setStatus('send failed: '+err.message, true); }
}
async function sendClear(){
setStatus('clearing...');
try{
const r = await fetch('/clear', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
setStatus('blanked');
} catch(err){ setStatus('clear failed: '+err.message, true); }
}
document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
document.getElementById('btn-wipe').addEventListener('click', sendClear);
fetch('/status').then(r => r.json()).then(j => {
setStatus('connected '+j.w+'x'+j.h);
}).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
"""
# =============================================================================
# WiFi
# =============================================================================
def _wifi_connect(ssid, password, timeout=60):
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
try:
wlan.config(pm=0xa11140) # disable power save BEFORE connecting
except Exception:
pass
if not wlan.isconnected():
print("Connecting to", repr(ssid))
if password:
wlan.connect(ssid, password)
else:
wlan.connect(ssid)
t0 = time.ticks_ms()
last_status = None
while not wlan.isconnected():
try:
st = wlan.status()
except Exception:
st = None
if st != last_status:
print(" wlan status:", st)
last_status = st
if time.ticks_diff(time.ticks_ms(), t0) > timeout * 1000:
raise RuntimeError(
"WiFi connect timeout (last status=%s). "
"Check: hotspot ON, 2.4 GHz / Maximize Compatibility ON, "
"WPA2 (not WPA3), correct password." % st
)
time.sleep(0.5)
ip = wlan.ifconfig()[0]
print("WiFi OK, IP =", ip)
return ip
# =============================================================================
# Bitmap unpack
# =============================================================================
def _blit_bitmap_to_fb(epd, payload):
src_stride = (EPD_WIDTH + 7) // 8
dst_stride = epd._fb_stride
h = EPD_HEIGHT
fb = epd._fb_buf
for i in range(len(fb)):
fb[i] = 0xFF
last_real_bits = EPD_WIDTH & 7
if last_real_bits:
keep = (0xFF << (8 - last_real_bits)) & 0xFF
pad = 0xFF ^ keep
else:
keep, pad = 0xFF, 0x00
n = len(payload)
for y in range(h):
so = y * src_stride
do = y * dst_stride
if so + src_stride > n:
break
for x in range(src_stride - 1):
fb[do + x] = payload[so + x]
last = payload[so + src_stride - 1]
if last_real_bits:
last = (last & keep) | pad
fb[do + src_stride - 1] = last
# =============================================================================
# HTTP helpers
# =============================================================================
def _sendall(conn, data):
"""Robust send -- MicroPython's socket.send() may not send everything,
and sendall() can fail on large buffers; chunk it explicitly."""
if isinstance(data, str):
data = data.encode("utf-8")
mv = memoryview(data)
total = len(mv)
sent = 0
chunk = 1024 # safe size for Pico W lwIP buffers
while sent < total:
end = sent + chunk
if end > total:
end = total
# Try send(); may return short
try:
n = conn.send(mv[sent:end])
except OSError as e:
# EAGAIN -> wait a bit and retry; otherwise give up
err = e.args[0] if e.args else 0
if err in (11, 35): # EAGAIN / EWOULDBLOCK
time.sleep_ms(5)
continue
raise
if n is None:
n = end - sent # some MP builds return None on success
if n <= 0:
time.sleep_ms(5)
continue
sent += n
def _send(conn, status, body, content_type="text/plain", extra=""):
if isinstance(body, str):
body = body.encode("utf-8")
head = (
"HTTP/1.1 {s}\r\n"
"Content-Type: {ct}\r\n"
"Content-Length: {n}\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"{x}"
"\r\n"
).format(s=status, ct=content_type, n=len(body), x=extra)
_sendall(conn, head)
_sendall(conn, body)
def _read_request(conn):
conn.settimeout(5.0)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = conn.recv(1024)
if not chunk:
break
buf += chunk
if len(buf) > 8192:
break
head, _, rest = buf.partition(b"\r\n\r\n")
lines = head.split(b"\r\n")
if not lines:
return None, None, {}, b""
try:
method, path, _ = lines[0].decode("utf-8").split(" ", 2)
except ValueError:
return None, None, {}, b""
headers = {}
for line in lines[1:]:
if b":" in line:
k, _, v = line.partition(b":")
headers[k.decode("utf-8").strip().lower()] = v.decode("utf-8").strip()
return method, path, headers, rest
def _read_exact(conn, n, already=b""):
out = bytearray(already)
while len(out) < n:
chunk = conn.recv(min(1024, n - len(out)))
if not chunk:
break
out += chunk
return bytes(out)
def _show_boot_screen(epd, ip):
fb = epd.fb
fb.fill(0xFF)
fb.rect(0, 0, EPD_WIDTH, EPD_HEIGHT, 0x00)
fb.text("PICO E-INK CANVAS", 8, 10, 0x00)
fb.hline(8, 24, EPD_WIDTH - 16, 0x00)
fb.text("WiFi: connected", 8, 38, 0x00)
fb.text("IP:", 8, 56, 0x00)
fb.text(ip, 40, 56, 0x00)
fb.text("Open in browser:", 8, 80, 0x00)
fb.text("http://" + ip + "/", 8, 96, 0x00)
epd.display_full()
# =============================================================================
# Main entry
# =============================================================================
def run(ssid=None, password=None, port=HTTP_PORT):
if ssid is None:
ssid = SSID
if password is None:
password = PASSWORD
if not ssid:
print("ERROR: create wifi_secrets.py with SSID and PASSWORD (copy from weather app pattern)")
raise SystemExit
epd = EPD()
epd.clear()
ip = _wifi_connect(ssid, password)
_show_boot_screen(epd, ip)
addr = socket.getaddrinfo("0.0.0.0", port)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(2)
print("HTTP server listening on", addr)
print("Open http://%s/ in your browser." % ip)
full_refresh_counter = 0
try:
while True:
try:
conn, client = s.accept()
except OSError:
continue
try:
method, path, headers, leftover = _read_request(conn)
if method is None:
_send(conn, "400 Bad Request", "bad request")
continue
qpos = path.find("?")
query = ""
if qpos >= 0:
query = path[qpos + 1:]
path = path[:qpos]
print(method, path, "from", client)
if method == "GET" and path in ("/", "/index.html"):
_send(conn, "200 OK", HTML_PAGE,
content_type="text/html; charset=utf-8")
elif method == "GET" and path == "/status":
_send(conn, "200 OK",
'{"ok":true,"w":%d,"h":%d,"bytes":%d}' % (
EPD_WIDTH, EPD_HEIGHT, EXPECTED_BYTES),
content_type="application/json")
elif method == "OPTIONS":
_send(conn, "204 No Content", "",
extra=("Access-Control-Allow-Methods: GET,POST,OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"))
elif method == "POST" and path == "/clear":
epd.clear()
full_refresh_counter = 0
_send(conn, "200 OK", '{"ok":true}', content_type="application/json")
elif method == "POST" and path == "/draw":
try:
n = int(headers.get("content-length", "0"))
except ValueError:
n = 0
if n <= 0 or n > 16384:
_send(conn, "400 Bad Request", "bad length")
continue
body = _read_exact(conn, n, leftover)
if len(body) < n:
_send(conn, "400 Bad Request", "short body")
continue
_blit_bitmap_to_fb(epd, body)
mode = "partial"
if "mode=full" in query:
mode = "full"
full_refresh_counter += 1
if full_refresh_counter >= 12:
mode = "full"
full_refresh_counter = 0
t0 = time.ticks_ms()
if mode == "full":
epd.display_full()
else:
epd.display_partial()
dt = time.ticks_diff(time.ticks_ms(), t0)
print(" drew %d bytes, mode=%s, %dms" % (len(body), mode, dt))
_send(conn, "200 OK",
'{"ok":true,"mode":"%s","ms":%d}' % (mode, dt),
content_type="application/json")
else:
_send(conn, "404 Not Found", "no")
except Exception as e:
print("req error:", e)
try:
_send(conn, "500 Internal Server Error", str(e))
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
gc.collect()
except KeyboardInterrupt:
print("stopped")
finally:
try:
s.close()
except Exception:
pass
if __name__ == '__main__':
run()
else:
run()Testing
I tested:
- Pixel alignment from browser to e-ink
- Tool behavior (brush, eraser, fill, basic shapes)
- Endpoint behavior (
/status,/draw,/clear) - Partial refresh speed and full refresh recovery
- Repeated updates to observe e-ink ghosting behavior over time
Photo notes:
assets/week14-01.jpgandassets/week14-02.jpg: interface interaction and send controlsassets/week14-03.jpgtoassets/week14-05.jpg: hardware-side validation and e-ink output checks
The full flow worked on local Wi-Fi and produced usable custom sleep screens. Most importantly, it was stable enough to use repeatedly without restarting the board.
Results
The app now lets me draw and push custom monochrome graphics to the e-ink screen for my final project. This is now a real tool I can keep using, not just a one-week demo.
Working features:
- Drawing and editing on browser canvas
- Bitmap packing and transfer to Pico
- E-ink update via partial/full modes
- Fast repeat workflow for sleep-screen design
Reflection
The key lesson for me was to design for 1-bit output from the beginning, because e-ink constraints shape both UI behavior and data format. Another lesson was that a clean software architecture saves a lot of time once physical testing starts.
Next improvements:
- Add text tool and layout templates
- Save/load preset screens
- Add profile presets for daily final-project use