Skip to content

Instantly share code, notes, and snippets.

@oaustegard
Created September 4, 2026 15:19
Show Gist options
  • Select an option

  • Save oaustegard/3af4d5efd475f2ef1788caf5b22b1cc2 to your computer and use it in GitHub Desktop.

Select an option

Save oaustegard/3af4d5efd475f2ef1788caf5b22b1cc2 to your computer and use it in GitHub Desktop.
sips -j pelican.js
// A pelican riding a bicycle, for `sips -j pelican.js -o pelican.png`.
// Uses only the Canvas API that sips exposes: the Canvas object *is* the 2D
// context, there is no ellipse()/arcTo()/setLineDash(), output goes through
// new Output(canvas, path).addToQueue().
const W = 960, H = 600
const c = new Canvas(W, H)
const TAU = Math.PI * 2
const K = 0.5522847 // bezier circle constant
// ---------- helpers (sips has no ellipse(), so build one from beziers) ----------
function ellipsePath(cx, cy, rx, ry, rot) {
c.save()
c.translate(cx, cy)
c.rotate(rot || 0)
c.moveTo(rx, 0)
c.bezierCurveTo(rx, ry * K, rx * K, ry, 0, ry)
c.bezierCurveTo(-rx * K, ry, -rx, ry * K, -rx, 0)
c.bezierCurveTo(-rx, -ry * K, -rx * K, -ry, 0, -ry)
c.bezierCurveTo(rx * K, -ry, rx, -ry * K, rx, 0)
c.closePath()
c.restore()
}
function ellipse(cx, cy, rx, ry, rot, fill, stroke, lw) {
c.beginPath()
ellipsePath(cx, cy, rx, ry, rot)
if (fill) { c.fillStyle = fill; c.fill() }
if (stroke) { c.strokeStyle = stroke; c.lineWidth = lw || 2; c.stroke() }
}
function circle(cx, cy, r, fill, stroke, lw) {
c.beginPath()
c.arc(cx, cy, r, 0, TAU, false)
if (fill) { c.fillStyle = fill; c.fill() }
if (stroke) { c.strokeStyle = stroke; c.lineWidth = lw || 2; c.stroke() }
}
function line(x1, y1, x2, y2, color, lw, cap) {
c.beginPath()
c.moveTo(x1, y1)
c.lineTo(x2, y2)
c.strokeStyle = color
c.lineWidth = lw
c.lineCap = cap || 'round'
c.stroke()
}
function poly(pts, fill, stroke, lw) {
c.beginPath()
c.moveTo(pts[0][0], pts[0][1])
for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0], pts[i][1])
c.closePath()
if (fill) { c.fillStyle = fill; c.fill() }
if (stroke) { c.strokeStyle = stroke; c.lineWidth = lw || 2; c.lineJoin = 'round'; c.stroke() }
}
// ---------- palette ----------
const INK = '#2b2b2b'
const FRAME = '#c0392b'
const FRAME_DARK = '#8e2a20'
const STEEL = '#7f8c8d'
const FEATHER = '#fbfaf5'
const FEATHER_SHADE = '#d9d6cc'
const BEAK = '#f4b942'
const BEAK_DARK = '#d8952a'
const POUCH = '#f2a35e'
const LEG = '#e8892b'
const LEG_DARK = '#b8641a'
// ---------- sky ----------
const sky = c.createLinearGradient(0, 0, 0, 430)
sky.addColorStop(0, '#5dade2')
sky.addColorStop(1, '#d6eaf8')
c.fillStyle = sky
c.fillRect(0, 0, W, 430)
// sun with a soft halo
const halo = c.createRadialGradient(830, 95, 20, 830, 95, 110)
halo.addColorStop(0, 'rgba(255, 236, 160, 0.9)')
halo.addColorStop(1, 'rgba(255, 236, 160, 0)')
c.fillStyle = halo
c.fillRect(700, 0, 260, 230)
circle(830, 95, 42, '#ffe680')
// clouds
function cloud(x, y, s) {
c.fillStyle = 'rgba(255,255,255,0.92)'
c.beginPath()
c.arc(x, y, 22 * s, 0, TAU, false)
c.arc(x + 26 * s, y - 12 * s, 28 * s, 0, TAU, false)
c.arc(x + 58 * s, y - 4 * s, 22 * s, 0, TAU, false)
c.arc(x + 30 * s, y + 8 * s, 20 * s, 0, TAU, false)
c.fill()
}
cloud(90, 110, 1.1)
cloud(330, 70, 0.8)
cloud(620, 130, 0.7)
// distant hills
c.fillStyle = '#9fcf8e'
c.beginPath()
c.moveTo(0, 430)
c.bezierCurveTo(120, 330, 260, 340, 380, 400)
c.bezierCurveTo(470, 350, 560, 350, 640, 405)
c.bezierCurveTo(740, 330, 860, 340, 960, 410)
c.lineTo(960, 430)
c.closePath()
c.fill()
c.fillStyle = '#7bbf6a'
c.beginPath()
c.moveTo(0, 432)
c.bezierCurveTo(200, 390, 400, 400, 600, 425)
c.bezierCurveTo(760, 395, 880, 400, 960, 432)
c.lineTo(960, 445)
c.lineTo(0, 445)
c.closePath()
c.fill()
// ---------- road ----------
c.fillStyle = '#5b9e4c' // verge
c.fillRect(0, 430, W, 170)
c.fillStyle = '#4a4a4a' // asphalt
c.fillRect(0, 445, W, 95)
c.fillStyle = '#6e6e6e'
c.fillRect(0, 445, W, 4)
c.fillRect(0, 536, W, 4)
c.fillStyle = '#f1e5a6' // centre line
for (let x = 20; x < W; x += 90) c.fillRect(x, 490, 50, 5)
// ---------- bicycle geometry ----------
const R = 78 // wheel radius
const rear = [250, 412], front = [610, 412]
const BB = [415, 425]
const seatC = [362, 290]
const htTop = [560, 292], htBot = [580, 340]
const seatTop = [352, 258]
const bar = [548, 268]
// pedals
const crankLen = 46
const crankA = -0.7 // radians; near pedal up-front
const pedalNear = [BB[0] + crankLen * Math.cos(crankA), BB[1] + crankLen * Math.sin(crankA)]
const pedalFar = [BB[0] - crankLen * Math.cos(crankA), BB[1] - crankLen * Math.sin(crankA)]
// shadow on the road
c.fillStyle = 'rgba(0,0,0,0.18)'
c.beginPath()
ellipsePath(430, 500, 260, 12, 0)
c.fill()
function wheel(cx, cy) {
c.lineCap = 'butt'
circle(cx, cy, R, null, INK, 13) // tyre
circle(cx, cy, R - 9, null, '#c8ccd0', 4) // rim
c.strokeStyle = '#aab0b5'
c.lineWidth = 1.5
for (let i = 0; i < 18; i++) { // spokes
const a = (i / 18) * TAU
c.beginPath()
c.moveTo(cx + 8 * Math.cos(a + 0.6), cy + 8 * Math.sin(a + 0.6))
c.lineTo(cx + (R - 11) * Math.cos(a), cy + (R - 11) * Math.sin(a))
c.stroke()
}
circle(cx, cy, 8, STEEL, INK, 2) // hub
}
// far leg first, so the frame overlaps it
function leg(hip, knee, foot, dark) {
line(hip[0], hip[1], knee[0], knee[1], dark ? LEG_DARK : LEG, 11)
line(knee[0], knee[1], foot[0], foot[1], dark ? LEG_DARK : LEG, 9)
// webbed foot fanning forward from the pedal
poly([[foot[0] - 6, foot[1] - 2], [foot[0] + 26, foot[1] - 12], [foot[0] + 30, foot[1] - 2], [foot[0] + 24, foot[1] + 8]],
dark ? LEG_DARK : LEG, dark ? '#8a4a10' : LEG_DARK, 1.5)
}
leg([375, 235], [392, 372], pedalFar, true)
wheel(rear[0], rear[1])
// rear cog + chain
circle(rear[0], rear[1], 15, null, INK, 4)
// frame
c.lineCap = 'round'
c.lineJoin = 'round'
function tube(a, b, w) {
line(a[0], a[1], b[0], b[1], FRAME_DARK, w + 3)
line(a[0], a[1], b[0], b[1], FRAME, w)
}
tube(rear, BB, 7) // chainstay
tube(rear, seatC, 6) // seat stay
tube(BB, seatC, 8) // seat tube
tube(seatC, htTop, 8) // top tube
tube(BB, htBot, 9) // down tube
tube(htTop, htBot, 9) // head tube
tube(htBot, front, 6) // fork
line(seatC[0], seatC[1], seatTop[0], seatTop[1], STEEL, 5) // seat post
line(htTop[0], htTop[1], bar[0], bar[1], STEEL, 6) // stem
// chain (top run under tension, bottom run slack)
c.strokeStyle = '#3a3a3a'
c.lineWidth = 3
c.beginPath()
c.moveTo(rear[0], rear[1] - 15)
c.lineTo(BB[0], BB[1] - 32)
c.moveTo(rear[0], rear[1] + 15)
c.quadraticCurveTo(330, 470, BB[0], BB[1] + 32)
c.stroke()
// chainring + crank
circle(BB[0], BB[1], 32, null, INK, 5)
circle(BB[0], BB[1], 22, null, '#555', 2)
circle(BB[0], BB[1], 7, STEEL, INK, 2)
line(BB[0], BB[1], pedalFar[0], pedalFar[1], '#555', 6) // far crank arm
c.fillStyle = '#333'
c.fillRect(pedalFar[0] - 12, pedalFar[1] - 4, 24, 8) // far pedal
// saddle
c.fillStyle = INK
c.beginPath()
c.moveTo(seatTop[0] - 42, seatTop[1] + 2)
c.quadraticCurveTo(seatTop[0] - 30, seatTop[1] - 12, seatTop[0] - 10, seatTop[1] - 8)
c.quadraticCurveTo(seatTop[0] + 20, seatTop[1] - 12, seatTop[0] + 34, seatTop[1] - 2)
c.quadraticCurveTo(seatTop[0] + 24, seatTop[1] + 8, seatTop[0] - 10, seatTop[1] + 6)
c.quadraticCurveTo(seatTop[0] - 34, seatTop[1] + 12, seatTop[0] - 42, seatTop[1] + 2)
c.closePath()
c.fill()
wheel(front[0], front[1])
// handlebar (swept back so the wing can reach it) + grip
c.strokeStyle = STEEL
c.lineWidth = 6
c.beginPath()
c.moveTo(bar[0], bar[1])
c.quadraticCurveTo(bar[0] + 30, bar[1] - 6, bar[0] + 52, bar[1] + 6)
c.stroke()
line(bar[0], bar[1], bar[0] - 26, bar[1] + 4, STEEL, 6)
line(bar[0] + 42, bar[1] + 2, bar[0] + 56, bar[1] + 9, INK, 10) // grip
line(bar[0] - 12, bar[1] + 2, bar[0] - 28, bar[1] + 5, INK, 10) // grip
// ---------- pelican ----------
// tail feathers (behind the body)
poly([[295, 205], [205, 150], [215, 180], [200, 200], [225, 202], [218, 228], [300, 228]], FEATHER, FEATHER_SHADE, 2)
c.strokeStyle = FEATHER_SHADE
c.lineWidth = 1.5
c.beginPath()
c.moveTo(290, 210); c.lineTo(215, 178)
c.moveTo(288, 218); c.lineTo(214, 205)
c.moveTo(292, 224); c.lineTo(222, 226)
c.stroke()
// body: a fat egg leaning into the handlebars
const bodyGrad = c.createLinearGradient(300, 140, 380, 260)
bodyGrad.addColorStop(0, '#ffffff')
bodyGrad.addColorStop(0.7, FEATHER)
bodyGrad.addColorStop(1, FEATHER_SHADE)
ellipse(365, 190, 108, 66, 0.22, bodyGrad, FEATHER_SHADE, 2.5)
// belly shadow
c.fillStyle = 'rgba(120,120,110,0.07)'
c.beginPath()
ellipsePath(392, 226, 60, 20, 0.3)
c.fill()
// near leg (over the frame)
leg([395, 238], [432, 362], pedalNear, false)
line(BB[0], BB[1], pedalNear[0], pedalNear[1], '#444', 7) // near crank arm
c.fillStyle = '#222'
c.fillRect(pedalNear[0] - 13, pedalNear[1] - 4, 26, 9) // near pedal
// wing: shoulder on the back, reaching down to grab the bar
const wingGrad = c.createLinearGradient(380, 150, 560, 280)
wingGrad.addColorStop(0, '#ffffff')
wingGrad.addColorStop(1, '#e3e0d6')
c.beginPath()
c.moveTo(372, 148)
c.bezierCurveTo(430, 120, 500, 160, 545, 240) // leading edge
c.bezierCurveTo(552, 252, 556, 262, 556, 270) // wrist at the grip
c.lineTo(536, 276) // primaries
c.lineTo(540, 262)
c.lineTo(520, 274)
c.lineTo(526, 256)
c.lineTo(504, 262)
c.bezierCurveTo(470, 235, 420, 210, 390, 210) // trailing edge
c.bezierCurveTo(372, 200, 362, 170, 372, 148)
c.closePath()
c.fillStyle = wingGrad
c.fill()
c.strokeStyle = FEATHER_SHADE
c.lineWidth = 2.5
c.stroke()
// feather lines on the wing
c.strokeStyle = 'rgba(150,148,140,0.6)'
c.lineWidth = 1.5
c.beginPath()
c.moveTo(400, 165); c.bezierCurveTo(450, 175, 490, 205, 512, 250)
c.moveTo(395, 185); c.bezierCurveTo(440, 195, 475, 220, 496, 255)
c.moveTo(430, 145); c.bezierCurveTo(480, 165, 510, 200, 530, 250)
c.stroke()
// black wingtips
c.fillStyle = '#3d3d3d'
poly([[556, 270], [536, 276], [540, 262], [520, 274], [526, 256], [548, 250], [556, 262]], '#3d3d3d', null)
// neck: thick S-curve, dark outline then feather fill
c.lineCap = 'round'
c.beginPath()
c.moveTo(452, 172)
c.bezierCurveTo(500, 150, 468, 105, 505, 70)
c.strokeStyle = FEATHER_SHADE
c.lineWidth = 44
c.stroke()
c.strokeStyle = FEATHER
c.lineWidth = 38
c.stroke()
// throat shading (front of the neck)
c.strokeStyle = 'rgba(150,148,140,0.35)'
c.lineWidth = 3
c.beginPath()
c.moveTo(470, 178)
c.bezierCurveTo(515, 150, 486, 110, 520, 82)
c.stroke()
// head
const head = [514, 62]
circle(head[0], head[1], 30, FEATHER, FEATHER_SHADE, 2.5)
// crest (a few unruly feathers on the crown)
c.strokeStyle = FEATHER_SHADE
c.lineWidth = 3
c.lineCap = 'round'
c.beginPath()
c.moveTo(496, 38); c.quadraticCurveTo(486, 20, 494, 8)
c.moveTo(506, 34); c.quadraticCurveTo(504, 16, 514, 6)
c.moveTo(516, 34); c.quadraticCurveTo(522, 18, 534, 12)
c.stroke()
// beak: upper mandible
c.lineJoin = 'round'
poly([[532, 50], [700, 86], [702, 94], [538, 74]], BEAK, BEAK_DARK, 2)
// nostril groove along the culmen
line(548, 56, 690, 87, BEAK_DARK, 1.2)
// hook at the tip
poly([[694, 84], [706, 90], [700, 102], [692, 96]], BEAK_DARK, null)
// a fish tail poking out of the pouch (drawn first so the pouch edge covers its base)
poly([[640, 120], [668, 148], [676, 132], [690, 156], [672, 158]], '#6fa8c9', '#3d6f8f', 1.5)
line(660, 138, 672, 150, '#3d6f8f', 1)
// pouch: hangs from the lower mandible, bulging with something
const pouchGrad = c.createLinearGradient(540, 80, 600, 170)
pouchGrad.addColorStop(0, '#f6b073')
pouchGrad.addColorStop(1, '#e3893f')
c.beginPath()
c.moveTo(536, 76)
c.bezierCurveTo(548, 160, 640, 165, 700, 96)
c.lineTo(538, 78)
c.closePath()
c.fillStyle = pouchGrad
c.fill()
c.strokeStyle = BEAK_DARK
c.lineWidth = 2
c.stroke()
// pouch wrinkles
c.strokeStyle = 'rgba(160,90,30,0.45)'
c.lineWidth = 1.2
c.beginPath()
c.moveTo(560, 96); c.quadraticCurveTo(585, 150, 640, 132)
c.moveTo(552, 88); c.quadraticCurveTo(570, 130, 610, 130)
c.stroke()
// lower mandible edge
poly([[536, 74], [700, 92], [702, 98], [540, 84]], BEAK_DARK, null)
// eye
circle(526, 56, 8.5, '#ffffff', INK, 1.5)
circle(528, 57, 4.5, INK)
circle(529.5, 55, 1.6, '#ffffff')
// brow line for a little determination
c.strokeStyle = INK
c.lineWidth = 2
c.beginPath()
c.moveTo(514, 44); c.quadraticCurveTo(526, 40, 538, 46)
c.stroke()
// ---------- motion lines ----------
c.strokeStyle = 'rgba(60,60,60,0.35)'
c.lineWidth = 3
c.lineCap = 'round'
c.beginPath()
c.moveTo(60, 330); c.lineTo(130, 330)
c.moveTo(40, 360); c.lineTo(120, 360)
c.moveTo(75, 390); c.lineTo(140, 390)
c.stroke()
// ---------- write ----------
new Output(c, sips.outputPath || 'pelican.png').addToQueue()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment