Created
September 4, 2026 15:19
-
-
Save oaustegard/3af4d5efd475f2ef1788caf5b22b1cc2 to your computer and use it in GitHub Desktop.
sips -j pelican.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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