/** * The face artwork: everything that used to be a hand-drawn PNG in GIMP is now * drawn from parameters onto a canvas, which is then used as the texture of the * two overlay planes that already exist on the model. * * The plates have a linear UV mapping fitted to the original artwork, so drawing * in "texture pixels" lands exactly where the original artwork did. Every constant * below was measured from the original textures (see README for the numbers), so * the defaults reproduce the 2022 artwork while every part of it stays editable. * * Texture space: the artwork WINDOW, 1024 x 380, origin at the top-left, y grows * downwards. `canvasLayout` then says where that window sits on the bigger canvas * a face plate actually needs (see below), and `Face` hands the bounds back as * `limits`, so the drawing can clamp against the real edge instead of assuming * the window is all there is. * * glTF stores v downwards too, and the textures are uploaded with flipY = false, * so no flipping is needed anywhere. */ const TAU = Math.PI * 2; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const HUGE = 4000; /** * The artwork window: the rectangle the 2022 hand-drawn textures occupied. Every * constant below is written in this space. */ export const ART_WINDOW = { width: 1024, height: 380 }; /** Keep this much clear at the canvas border, so filtering has room to breathe. */ const EDGE_MARGIN = 8; /** What a drawing falls back to when no plate bounds are supplied. */ const WINDOW_LIMITS = { top: 0, bottom: ART_WINDOW.height, left: 0, right: ART_WINDOW.width, }; /** * The canvas a face plate's drawing needs, and where the artwork window sits on it. * * WHY: the plates are the *whole* front half of the body, so their UVs run far * outside the 0..1 the artwork was drawn in (measured on the shipped model: the * eye plate covers v -1.02..1.93, and 9 of 10 of its vertices sit within a * twentieth of the border). Everything outside 0..1 used to clamp to the canvas * edge row, so the moment a drawing reached the border - a tear dropped low, a * brow lifted, a thick mouth pushed down - that edge row was copied across the * rest of the plate and the mark stretched into a long smear. * * `Face` rewrites each plate's UVs so this range maps onto 0..1 instead, which * removes the clamp altogether. The map is affine, so the artwork keeps its exact * pixel size and position and merely shifts by `offset`; the canvas grows by the * same factor, and those extra rows are the room a moved tear, brow or mouth * needs (`limits` says where the real edge now is, in window coordinates). * * @param {{u0:number,u1:number,v0:number,v1:number}} range the plate's UV range * @param {{width:number,height:number}} [window] the artwork window */ export function canvasLayout(range, window = ART_WINDOW) { const u0 = Number.isFinite(range?.u0) ? range.u0 : 0; const u1 = Number.isFinite(range?.u1) ? range.u1 : 1; const v0 = Number.isFinite(range?.v0) ? range.v0 : 0; const v1 = Number.isFinite(range?.v1) ? range.v1 : 1; const du = Math.max(1e-6, u1 - u0); const dv = Math.max(1e-6, v1 - v0); const width = Math.max(1, Math.round(window.width * du)); const height = Math.max(1, Math.round(window.height * dv)); // `+ 0` turns a `-0` into a plain `0`, so the offsets compare cleanly. const offsetX = Math.round(-u0 * window.width) + 0; const offsetY = Math.round(-v0 * window.height) + 0; return { width, height, offsetX, offsetY, window, // The canvas edges in the artwork's own coordinates. `top` is negative when // the plate reaches above the window: that is the extra room. limits: { top: -offsetY + 0, bottom: height - offsetY, left: -offsetX + 0, right: width - offsetX, }, }; } /** Fixed layout of the eye plane, measured from the original `eyes-open.png`. */ export const EYE_LAYOUT = { radius: 84, irisRadius: 62.5, highlightRadius: 28, highlightOffset: { x: 29.5, y: -18 }, // The model's left eye (its own left, +x) sits in the u > 0.5 half. eyes: [ { key: 'right', cx: 267.5, cy: 240, towardNose: 1 }, { key: 'left', cx: 755.5, cy: 240, towardNose: -1 }, ], lidRadiusFactor: 1.35, lidStroke: 14, // Eyebrows are painted into the same texture as the eyes. The eye plane is a // flat-ish patch on a round head, so only its lower part is really outside // the head: a brow high on the forehead would be swallowed by it. The brow // therefore sits just above the eyeball (row 156) and the eyeball is drawn // over its lower edge afterwards. // The brow sits just above the eyeball, and the eyeball is drawn over its // lower edge. `lift` is that resting height, so `height: 0` is the brow's // natural place and a POSITIVE height raises it (a negative one sinks it // towards the eye, where the eyeball will cover it). brow: { lift: 114, length: 134, thickness: 15, curve: 0.14, offsetX: 6 }, // A teardrop hangs well below the eye. The plate keeps the whole canvas, so // the drop can sit low without being clipped (measured with a ruler grid). tear: { size: 34, offsetX: 66, offsetY: 88 }, closedLine: { // The single shut line is the *long* form of the eyelid, the same reach as // the arms of the "ぎゅっ" below, so switching between 1 and 3 lines does not // change how wide the eye reads. length: 205, offsetX: 6, offsetY: -10, slantDeg: 3.9, bow: 3, // 2- and 3-line shut eyes, copied from the original `eyes-close-tight` // artwork: three strokes sharing one vertex that points at the nose, opening // to a wide bird's foot / arrow shape. `spread` is half the height the arms // open to at the far end. armSpan: 205, spread: 62, vertex: 87.5, armX: 104, }, arch: { edgesUp: 8, apexUp: 44 }, }; /** * ほっぺ (a manga blush) and 頭の模様 (a mark on the head), both drawn into the eye * plate next to the eyes and brows. * * They are deliberately *generic* manga devices, not a copy of any one character: * a pink patch crossed by short diagonal strokes is a stock convention, and the * head mark is one of a few simple shapes (a hook, a spiral, three strokes, a dot * row) that starts off. Both stay editable through the panel. The cheek positions * are tied to `EYE_LAYOUT` so they sit beside the eyes; the head mark sits up in * the plate's upper area, i.e. on the forehead above the brows. */ export const CHEEK_LAYOUT = { drop: 64, // how far below the eye centre the patch sits (artwork px) outward: 66, // how far outwards, away from the midline, it sits radius: 50, // half-width of the patch squash: 0.55, // half-height, as a fraction of the half-width (a flat ellipse) // The blush is *always* four strokes - the reference artwork has four - so // there is no line-count control any more. The strokes are horizontal and // hand-drawn: each wobbles a little and tapers to a point at both ends. lines: 4, lineWidth: 10, // the stroke thickness at its widest (artwork px) spread: 0.62, // how far the outermost stroke sits from the centre (x half-height) margin: 0.18, // clear gap left between a stroke's end and the patch outline wobble: 0.32, // sideways wander, as a fraction of the stroke thickness }; /** * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol. * * The face plate is a shell of the *whole* front half of the body, so the plate's * own edge is the head's front silhouette. Filling the plate from its top edge * down to an adjustable boundary therefore makes the covering's outline follow * the silhouette for free - there is no separate ellipse to keep in step. * * It is a generic device: a colour cap, a fringe, a side part, or a left/right * two-tone split. It is deliberately *not* a copy of any one character's hair. * It starts off. */ export const HEAD_MARK_LAYOUT = { // Where the boundary sits by default: on the forehead, just above the brows // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it). cy: 120, reach: 190, // artwork px the boundary moves per unit of `size` }; /** * 頭の模様 on the *hair plate*: the head's cover when the model carries a plate * that wraps all the way round (material `hair-plate`, cylindrical UV). * * The front plate only reaches the head's silhouette, so hair drawn on it stops * at the front half. The hair plate's UV is `u` = angle round the head (front at * 0.5, back at the seam 0/1) and `v` = height (0 at the model's feet, 1 at the * top of the head), so the covering is everything *above* a boundary line: a cap * that is visible from behind as well. */ export const HAIR_WRAP_LAYOUT = { // The wrap's v runs top-of-head (0) to the model's feet (1) after the GLB's // V-flip, so `base` is how far down from the top the hairline sits at size 1. // `reach` is how much a unit of `size` moves it; `offsetScale` converts the // `offsetY` slider (artwork px) to the same units, so one slider drives both. base: 0.30, reach: 0.30, offsetScale: 0.30 / 190, }; /** * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate * at the nose, the same spot the snot bubble uses. `shape` picks a well-known * kind; `size` scales it about the nose, and the colours are adjustable. */ export const BEARD_LAYOUT = { cx: 511.5, cy: 352, // just under the nose width: 240, // base width of the widest beard at size 1 }; /** * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows. * * Not measured off any artwork - the character has no glasses - but the numbers * are tied to the eyeball so a lens is always a little wider than the eye it * covers. The lens centre sits *below* the eye centre because the brow is drawn * first, just above the eyeball (see `EYE_LAYOUT.brow`): a lens wide enough to * read as glasses would otherwise cut straight through it. * * The two kinds get separate shapes on purpose. They used to share one ellipse * and differ only in how dark the lens was filled, which made them hard to tell * apart; the round 眼鏡 below and the wide, angular サングラス in * `SUNGLASSES_LAYOUT` now read as different objects at a glance. */ const GLASSES_LAYOUT = { widthFactor: 1.08, // lens half-width, as a multiple of the eyeball radius heightFactor: 1.0, // a circle, so the round 眼鏡 stays round (a flatter // ellipse used to drift towards the shades below) drop: 20, // the lens centre hangs this far below the eye centre (artwork px) bridgeRise: 8, // how much the bridge arcs up over the nose bridgeWidth: 0.75, // bridge thickness, as a fraction of the frame stroke templeLength: 1.5, // temple stub length, as a multiple of the eyeball radius templeRise: 30, // how far the temple climbs towards the side of the head }; /** * サングラス: a long, pointed cat-eye rather than the round 眼鏡 lens. Each side is a * slim wedge whose *outer end starts low*, rises to a *high, pointed outer corner* * set further out (`tipX`), and whose top edge then sweeps back down towards the * nose - the "外側が長くとがって上に上がる" shape the reference shows. The inner end is * short, so the lens tapers inwards. The eye is allowed to poke out above and * below it, so the lens no longer has to cover the whole eyeball. A level bar * joins the two inner ends, so the pair still reads as one dark visor. The ratios * are of the eyeball radius, exactly like `GLASSES_LAYOUT`, so `scale` means the * same thing for both kinds. */ const SUNGLASSES_LAYOUT = { widthFactor: 1.6, // a long lens: the outer end reaches well past the eyeball heightFactor: 0.58, // slim, so the eye is free to show above and below it drop: 8, // sits a little lower than the round pair corner: 0.05, // corner rounding, as a fraction of the half-height; kept // tiny so the outer corner stays pointed topSkew: 1.0, // the pointed corner rises this far above the lens top (x half-height) innerTop: 0.42, // the inner top is low, which is what makes the top edge climb innerBottom: 0.34, // the inner bottom is pinched up towards the nose (x half-height) outerEndX: 0.75, // the low outer end sits this far out (x half-width) outerBottom: 0.82, // ...and this deep (x half-height) tipX: 1.22, // the pointed corner juts this far out (x half-width) bridgeWidth: 1.15, // a short, thick bar - thicker than the frame stroke bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height) templeLength: 1.15, // a short stub, like the round pair's but a little shorter templeRise: 24, // A heavy rim. It used to be slimmer than the round pair's stroke, which read // as a thin pair of shades; the reference look is a chunky frame. frameFactor: 1.6, }; /** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole * 1024 x 380 canvas (see tools/build-face-plates.py), so the drawing no longer * has to be squeezed into a band. The only limit left is the canvas itself. */ export const MOUTH_LAYOUT = { centreX: 511.5, chordY: 88, halfChord: 420.5, sag: 177, // With the ends and the depth both fixed - which is what the slider promises - // the only thing left to choose about the curve is *where* it bends. 1/3 is a // quadratic Bézier (a parabola): it is flattest at the apex and falls away // fastest near the ends. Pulling the cubic's control points in towards the ends // makes the middle of the smile straighter still and lets the fall happen near // the corners, which is what reads as a *gentler* curve at the same depth. sagBend: 0.2, thickness: 21, // Kept for reference: the 2022 plane only showed these rows. bandTop: 46, bandBottom: 332, // Only used to cap the *size* of the round "O" mouth, so the surprise face // stays a mouth and not a hole. The mouth's travel no longer stops here: the // drawing is given the plate's real edges through `limits`. safeBottom: 372, // The nose is a separate mesh that pokes out in front of the plate. The plate // is depth tested, so the nose hides whatever is drawn behind it - but a frown // arcs *up* into that hiding place, so slide the mouth down until the middle // of the arc clears the nose. Only the middle is checked: a smile curves away // from the nose, and checking its ends instead is what used to pin the whole // mouth in place and make the height slider do nothing. noseClear: 132, // The original hand-drawn mouth is a *shallow* arc with tall corner strokes // flicking up at the ends (measured from the artwork: the arc's own sag is // ~0.13 of its chord, while the corners reach ~90px above it). Keeping that // split is what makes `smile: 1` read as the original; making the arc itself // deep instead looked too steep. // // The tongue rises from the lip line to a rounded top just above the corner // strokes. Measured off the original: its crown is a *super-ellipse* - // `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth, // almost flat top. It is convex upwards, but it is **not** a point. tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 }, corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 }, openRise: 64, // Was 0.25: opening the mouth used to flatten the smile to stay inside the // texture band. There is no band to stay inside now. openFlatten: 0, }; /* ------------------------------------------------------------------ helpers */ function circlePath(ctx, cx, cy, r) { ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.closePath(); } function fillCircle(ctx, cx, cy, r, color) { circlePath(ctx, cx, cy, r); ctx.fillStyle = color; ctx.fill(); } /** * A heart, used for the "love" eyes. The path is wider than it is tall, which * is what makes it read as a heart rather than a blob at small sizes. */ function heartPath(ctx, cx, cy, r) { ctx.beginPath(); ctx.moveTo(cx, cy + r * 0.80); ctx.bezierCurveTo(cx - r * 1.24, cy - r * 0.34, cx - r * 0.50, cy - r * 1.18, cx, cy - r * 0.40); ctx.bezierCurveTo(cx + r * 0.50, cy - r * 1.18, cx + r * 1.24, cy - r * 0.34, cx, cy + r * 0.80); ctx.closePath(); } function fillHeart(ctx, cx, cy, r, color) { heartPath(ctx, cx, cy, r); ctx.fillStyle = color; ctx.fill(); } /** A teardrop, used by the crying expression. */ function dropPath(ctx, cx, cy, size) { ctx.beginPath(); ctx.moveTo(cx, cy - size * 1.32); ctx.bezierCurveTo(cx + size * 0.95, cy - size * 0.34, cx + size * 0.95, cy + size * 0.78, cx, cy + size * 0.78); ctx.bezierCurveTo(cx - size * 0.95, cy + size * 0.78, cx - size * 0.95, cy - size * 0.34, cx, cy - size * 1.32); ctx.closePath(); } function strokeCircle(ctx, cx, cy, r, color, width) { circlePath(ctx, cx, cy, r); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke(); } function tracePolyline(ctx, points, closed) { if (!points.length) return; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y); if (closed) ctx.closePath(); } function strokePolyline(ctx, points, { color, width, closed = false }) { if (points.length < 2) return; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; tracePolyline(ctx, points, closed); ctx.stroke(); ctx.restore(); } function fillPolygon(ctx, points, color) { if (points.length < 3) return; ctx.save(); ctx.fillStyle = color; tracePolyline(ctx, points, true); ctx.fill(); ctx.restore(); } /** * Trace a closed polygon with rounded corners. Used for the squarish * サングラス lens: an ellipse cannot be angular, and a plain polygon has cusps. * Each corner is cut back by `radius` (clamped so short edges cannot overlap) * and joined with a quadratic through the original vertex. */ function traceRoundedPolygon(ctx, points, radius) { const n = points.length; ctx.beginPath(); for (let i = 0; i < n; i++) { const prev = points[(i + n - 1) % n]; const cur = points[i]; const next = points[(i + 1) % n]; const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1; const outLen = Math.hypot(next.x - cur.x, next.y - cur.y) || 1; const cut = Math.min(radius, inLen / 2, outLen / 2); const from = { x: cur.x + ((prev.x - cur.x) / inLen) * cut, y: cur.y + ((prev.y - cur.y) / inLen) * cut }; const to = { x: cur.x + ((next.x - cur.x) / outLen) * cut, y: cur.y + ((next.y - cur.y) / outLen) * cut }; if (i === 0) ctx.moveTo(from.x, from.y); else ctx.lineTo(from.x, from.y); ctx.quadraticCurveTo(cur.x, cur.y, to.x, to.y); } ctx.closePath(); } /** Points along a quadratic Bézier, `steps` segments (steps + 1 points). */ function quadraticPoints(p0, p1, p2, steps) { const out = []; for (let i = 0; i <= steps; i++) { const t = i / steps; const u = 1 - t; out.push({ x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x, y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y, }); } return out; } /** A point on a cubic Bézier. */ function cubicPoint(p0, p1, p2, p3, t) { const u = 1 - t; const a = u * u * u; const b = 3 * u * u * t; const c = 3 * u * t * t; const d = t * t * t; return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y, }; } /** Points along a cubic Bézier, `steps` segments (steps + 1 points). */ function cubicPoints(p0, p1, p2, p3, steps) { const out = []; for (let i = 0; i <= steps; i++) out.push(cubicPoint(p0, p1, p2, p3, i / steps)); return out; } /** * The four control points of the mouth's centreline: the same two ends, the same * depth, but `bend` decides where the curve actually bends (see `sagBend`). */ function mouthControls(left, right, sag, bend) { const width = right.x - left.x; const y = left.y + (4 / 3) * sag; return { p0: left, p1: { x: left.x + width * bend, y }, p2: { x: right.x - width * bend, y }, p3: right, }; } function rotate(points, pivot, degrees) { if (!degrees) return points; const a = (degrees * Math.PI) / 180; const cos = Math.cos(a); const sin = Math.sin(a); return points.map((p) => { const dx = p.x - pivot.x; const dy = p.y - pivot.y; return { x: pivot.x + dx * cos - dy * sin, y: pivot.y + dx * sin + dy * cos }; }); } /* --------------------------------------------------------------------- eyes */ /** * Intersect the canvas clip with the eyeball disc and the (possibly closed) * lids. Because the overlay plane is transparent and sits in front of the face, * clipping is all that is needed: whatever is clipped away shows the real * shaded face behind it. That is what makes the eyelid blend perfectly without * baking a face-coloured background into the texture. */ function clipEye(ctx, eye, open, lower, flat = false) { circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); const upper = 1 - clamp(open, 0, 1); if (upper > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const lowest = eye.cy - eye.r + 2 * eye.r * upper; ctx.beginPath(); if (flat) { // A flat lid: a straight edge straight across the eye. ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE); } else { const cy = lowest - rl; ctx.moveTo(eye.cx - HUGE, cy); ctx.lineTo(eye.cx - rl, cy); ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down ctx.lineTo(eye.cx + HUGE, cy); ctx.lineTo(eye.cx + HUGE, cy + HUGE); ctx.lineTo(eye.cx - HUGE, cy + HUGE); } ctx.closePath(); ctx.clip(); } const bottom = clamp(lower, 0, 1); if (bottom > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const highest = eye.cy + eye.r - 2 * eye.r * bottom; ctx.beginPath(); if (flat) { ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE); } else { const cy = highest + rl; ctx.moveTo(eye.cx - HUGE, cy); ctx.lineTo(eye.cx - rl, cy); ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up ctx.lineTo(eye.cx + HUGE, cy); ctx.lineTo(eye.cx + HUGE, cy - HUGE); ctx.lineTo(eye.cx - HUGE, cy - HUGE); } ctx.closePath(); ctx.clip(); } } function lidPath(ctx, eye, amount, lower, flat = false) { ctx.beginPath(); if (flat) { // A straight lid edge; the caller clips it to the eyeball. const y = lower ? eye.cy + eye.r - 2 * eye.r * amount : eye.cy - eye.r + 2 * eye.r * (1 - amount); ctx.moveTo(eye.cx - eye.r * 2, y); ctx.lineTo(eye.cx + eye.r * 2, y); return; } const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; if (!lower) { const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount); ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true); } else { const highest = eye.cy + eye.r - 2 * eye.r * amount; ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false); } } /** The "eyes shut" artwork: a line, a chevron or a happy arch. */ function drawShutEye(ctx, eye, spec, style) { const layout = EYE_LAYOUT.closedLine; const { line, width } = style; if (spec.closed === 'chevron') { // The chevron points *at* the nose, matching the original artwork. const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 }; const armX = eye.cx - eye.towardNose * layout.armX; const spread = layout.spread * (spec.spread ?? 1); strokePolyline(ctx, [vertex, { x: armX, y: vertex.y - spread }], { color: line, width }); strokePolyline(ctx, [vertex, { x: armX, y: vertex.y + spread }], { color: line, width }); return; } if (spec.closed === 'three') { // A real "3": an upper bowl and a lower bowl that meet at a single pinch on // the nose side. Stacking two C's instead made the two lobes sit on top of // each other; joining them at a point is what makes it read as the digit. // `length` scales the whole shape. const r = 34 * (spec.length ?? 1); // Which way the digit faces. It used to follow the nose side, which made the // pair a mirror image; each eye can now be set either way, because a pair of // mirrored 3s does not always read the way you want. const dir = (spec.threeFlip ? -1 : 1) * eye.towardNose; const sx = eye.cx; const cy = eye.cy + layout.offsetY; const at = (x, y) => ({ x: sx + dir * r * x, y: cy + r * y }); const pinch = at(0.72, 0); const upper = quadraticPoints(at(0.02, -1.62), at(2.05, -1.40), pinch, 26); const lower = quadraticPoints(pinch, at(2.05, 1.46), at(0.02, 1.78), 26); strokePolyline(ctx, upper, { color: line, width }); strokePolyline(ctx, lower, { color: line, width }); return; } if (spec.closed === 'arch') { const edges = { x: eye.r, y: EYE_LAYOUT.arch.edgesUp }; const points = quadraticPoints( { x: eye.cx - edges.x, y: eye.cy + edges.y }, { x: eye.cx, y: eye.cy + edges.y - EYE_LAYOUT.arch.apexUp * 2 }, { x: eye.cx + edges.x, y: eye.cy + edges.y }, 24, ); strokePolyline(ctx, points, { color: line, width }); return; } // Default: the gently slanted line of the original artwork, mirrored so it // always slopes down towards the nose. With `closedLines` set to 2 or 3 the // strokes share one endpoint instead - a ">" or bird's-foot shape, which is // how manga draws a happily squeezed-shut eye. const count = clamp(Math.round(spec.closedLines ?? 1), 1, 3); if (count >= 2) { const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 }; // Reach as far as the single line does, not just a short stub: the arms are // the *long* form of the same eyelid the one-line version covers. const outX = -eye.towardNose * layout.armSpan; const arms = count === 2 ? [{ x: outX, y: -layout.spread }, { x: outX, y: layout.spread }] : [ { x: outX, y: -layout.spread }, { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread), y: 0 }, { x: outX, y: layout.spread }, ]; for (const arm of arms) { strokePolyline(ctx, [vertex, { x: vertex.x + arm.x, y: vertex.y + arm.y }], { color: line, width }); } return; } const half = (layout.length / 2) * (spec.length ?? 1); const midX = eye.cx - eye.towardNose * layout.offsetX; const midY = eye.cy + layout.offsetY; const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180; const bow = (layout.bow ?? 0) * (spec.slant ?? 1); const from = { x: midX - Math.cos(angle) * half, y: midY - Math.sin(angle) * half }; const to = { x: midX + Math.cos(angle) * half, y: midY + Math.sin(angle) * half }; const points = quadraticPoints(from, { x: midX, y: midY + bow * 2 }, to, 20); strokePolyline(ctx, points, { color: line, width }); } /** * A stroke whose width runs from `w0` at the first point to `w1` at the last. * * Used for a ゴルゴ13-style brow: a filled wedge that comes to a point at one end * reads as a heavy eyebrow, where a constant-width stroke reads as a soft arc. */ function taperedStroke(ctx, points, w0, w1, color) { const count = points.length; if (count < 2) return; const side = [[], []]; for (let i = 0; i < count; i++) { const t = i / (count - 1); const w = (w0 + (w1 - w0) * t) / 2; const before = points[Math.max(0, i - 1)]; const after = points[Math.min(count - 1, i + 1)]; const dx = after.x - before.x; const dy = after.y - before.y; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len; const ny = dx / len; side[0].push({ x: points[i].x + nx * w, y: points[i].y + ny * w }); side[1].push({ x: points[i].x - nx * w, y: points[i].y - ny * w }); } ctx.save(); tracePolyline(ctx, [...side[0], ...side[1].reverse()], true); ctx.fillStyle = color; ctx.fill(); ctx.restore(); } /** An eyebrow: a short arc above the eye, mirrored between the two eyes so a * positive `angle` always means "inner end down" (an angry brow). */ function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) { const brow = style.brow; if (!brow?.enabled) return; const layout = EYE_LAYOUT.brow; // 0 is allowed: a zero-length stroke with round caps is a dot, which is what a // length of 0 is asking for. const length = Math.max(0, (brow.length ?? 1) * layout.length); const thickness = Math.max(1, (brow.thickness ?? 1) * layout.thickness); const cy = eye.cy - layout.lift - (brow.height ?? 0) - (spec.browHeight ?? 0); // `spacing` widens the gap between the two brows: a positive value moves each // one away from the nose. `layout.offsetX` is the resting inset from the // artwork (which the old code read off the wrong object, so it never applied). const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0)); const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180; const half = length / 2; const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half }; const to = { x: cx + Math.cos(angle) * half, y: cy + Math.sin(angle) * half }; const bow = (brow.curve ?? layout.curve) * length; const points = quadraticPoints(from, { x: cx, y: cy - bow }, to, 16); // A brow raised with the height slider used to run off the top of the canvas, // where the clamped edge row was copied across the whole plate and the brow // smeared upwards. Keep the whole stroke inside the artwork instead. const top = limits.top + EDGE_MARGIN; const highest = Math.min(...points.map((q) => q.y)) - thickness / 2; if (highest < top) for (const q of points) q.y += top - highest; // `taper` is how wide the *inner* (nose-side) end is: 1 = a plain stroke, and // lower values turn the brow into a wedge that comes to a point at the nose. const taper = clamp(brow.taper ?? 1, 0, 1); const color = brow.color ?? style.line ?? '#55386e'; // A tapered wedge has no shape at zero length, so a dot always takes the plain // round-capped stroke. if (taper >= 0.999 || length < 2) { strokePolyline(ctx, points, { color, width: thickness }); return; } const innerIsTo = eye.towardNose > 0; taperedStroke( ctx, points, thickness * (innerIsTo ? 1 : taper), thickness * (innerIsTo ? taper : 1), color, ); } /* ------------------------------------------------------- ほっぺ / 頭の模様 */ /** * ほっぺ: a soft pink patch crossed by short diagonal hatch strokes, the classic * manga blush. Drawn once per cheek; the caller draws these before the eyes. */ function drawCheek(ctx, cx, cy, spec) { const size = clamp(spec.size ?? 1, 0.2, 3); const r = CHEEK_LAYOUT.radius * size; const ry = r * CHEEK_LAYOUT.squash; ctx.save(); ctx.beginPath(); ctx.ellipse(cx, cy, r, ry, 0, 0, TAU); ctx.fillStyle = spec.color ?? '#f6a6b8'; ctx.fill(); // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge. ctx.clip(); // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a // point at both ends and wanders a little. Each one stops short of the patch // outline - the margin is measured against the patch's own width at that // height, so a stroke on the narrow top or bottom cannot reach the edge. const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size); ctx.fillStyle = spec.hatchColor ?? '#e0708f'; for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) { const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1 const dy = t * ry * CHEEK_LAYOUT.spread; const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2)); const half = chord - r * CHEEK_LAYOUT.margin; if (half < 4) continue; traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9); } ctx.restore(); } /** * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both * ends, with a gentle wobble so it reads as drawn rather than printed. */ function traceHandHatch(ctx, cx, cy, half, thickness, seed) { const steps = 12; const top = []; const bottom = []; for (let i = 0; i <= steps; i += 1) { const u = i / steps; const x = cx + (u - 0.5) * 2 * half; const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble; const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends top.push({ x, y: cy + wobble - w }); bottom.push({ x, y: cy + wobble + w }); } ctx.beginPath(); ctx.moveTo(top[0].x, top[0].y); for (const point of top.slice(1)) ctx.lineTo(point.x, point.y); for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y); ctx.closePath(); ctx.fill(); } /** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */ function drawCheeks(ctx, spec) { if (!spec?.enabled) return; const drop = CHEEK_LAYOUT.drop + clamp(spec.offsetY ?? 0, -400, 400); const outward = CHEEK_LAYOUT.outward + clamp(spec.spacing ?? 0, -400, 400); for (const layout of EYE_LAYOUT.eyes) { // Outwards is away from the midline, i.e. the opposite of `towardNose`. const cx = layout.cx - layout.towardNose * outward; drawCheek(ctx, cx, layout.cy + drop, spec); } } /** * 頭の模様: a hair-like covering over the whole head. * * The face plate is a shell of the front half of the body, so its own edge is * the head's silhouette. This fills the plate from its top edge down to an * adjustable boundary, which is what makes the covering follow the head outline * without knowing the head's shape ahead of time. The boundary style, colour and * size are all adjustable, and one style (`split`) divides the head into two * colours along a vertical curve - a generic "two-tone head" device. It is not a * copy of any particular character's hair. */ function drawHeadCover(ctx, spec, limits) { const shape = spec?.shape ?? 'off'; if (shape === 'off' || shape === 'none' || shape === 'wave') return; const size = clamp(spec.size ?? 1, 0.02, 3); const left = limits.left; const right = limits.right; const top = limits.top; const bottom = limits.bottom; const width = right - left; const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600); const color = spec.color ?? '#8a4fe0'; const color2 = spec.color2 ?? '#ffffff'; ctx.save(); // Stay on the plate: nothing may spill past the artwork's real edges. ctx.beginPath(); ctx.rect(left, top, width, bottom - top); ctx.clip(); if (shape === 'split') { // Retired: the two-tone head was dropped, but an old saved state may still // carry the shape, so treat it as off rather than drawing a two-tone head. ctx.restore(); return; } // The boundary curve. `t` runs -1..1 across the plate, so each style is a // small shape function; the covering is everything above it. const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach + clamp(spec.offsetY ?? 0, -600, 600); const halfSpan = width / 2; const boundary = (x) => { const t = clamp((x - cx) / halfSpan, -1, 1); // まえがみ: a narrow, deep V pointing down the middle (Atom / Mickey). if (shape === 'fringe') return base + 175 * Math.max(0, 1 - Math.abs(t) * 1.6); // はちわれ: the same V upside down - a widow's peak. if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5); // カーブ: a plain shallow curve, higher (more hair) round the sides. if (shape === 'curve') return base + 160 * t * t; // わけめ: a 7:3 parting - a step at 30% from the left. if (shape === 'side') return base + 60 - 320 / (1 + Math.exp(-(t + 0.4) * 10)); // `cap`: a rounded helmet, lowest in the middle. return base - 100 * (1 - t * t); }; const steps = 48; ctx.beginPath(); ctx.moveTo(left, top); ctx.lineTo(right, top); for (let i = steps; i >= 0; i -= 1) { const x = left + (width * i) / steps; ctx.lineTo(x, boundary(x)); } ctx.closePath(); ctx.fillStyle = color; ctx.fill(); if (shape === 'side') { // わけめ: cut a real parting in the hair, so the two halves read as combed // apart rather than as one smooth sweep. const partX = cx - halfSpan * 0.4; const gw = Math.max(5, width * 0.02) * (0.6 + size); ctx.save(); ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); ctx.moveTo(partX - gw * 0.16, top); ctx.lineTo(partX + gw * 0.16, top); ctx.lineTo(partX + gw * 0.7, boundary(partX) + 4); ctx.lineTo(partX - gw * 0.7, boundary(partX) + 4); ctx.closePath(); ctx.fill(); ctx.restore(); } ctx.restore(); } /** * 頭の模様 drawn onto the hair plate, which wraps right round the head. * * The plate's canvas is the cylindrical UV unrolled: `x` is the angle (front at * the middle, the seam at the back at both ends) and `y` is the height. The GLB * flips V on export, so the top of the head lands at the top of the canvas and * the covering is everything above the boundary line - the same direction as * `drawHeadCover`, which fills down from the top edge of the front plate. * * The same `shape` names are reused, with their front-to-back modulation tied to * how far the column leans towards the front (`t`). */ export function drawHeadCoverWrap(ctx, spec, layout) { const shape = spec?.shape ?? 'off'; // `split` and `wave` are retired front-plate shapes; they have no wrap equal. if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave') return; const size = clamp(spec.size ?? 1, 0.02, 3); const W = layout.width; const H = layout.height; // The horizontal axis is the angle. The *front* sits at u = 0.5, which is the // middle of the artwork window (1024), not the middle of the canvas: after the // seam heal the canvas is wider than the window, so using W/2 put the pattern // a good way off centre. const span = (layout.window ?? ART_WINDOW).width; // After the GLB's V-flip the wrap's v is 0 at the top of the head and 1 at the // model's feet, so a *larger* boundary means more hair (it reaches further // down) - the same direction as `size` and `offsetY` on the front plate. const base = HAIR_WRAP_LAYOUT.base + (size - 1) * HAIR_WRAP_LAYOUT.reach + clamp(spec.offsetY ?? 0, -600, 600) * HAIR_WRAP_LAYOUT.offsetScale; const color = spec.color ?? '#8a4fe0'; ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip(); // `t` is -1..1 across the front (+-90 deg around the front); everything behind // is pinned to +-1, so the hairline runs flat round the back of the head. const half = span * 0.25; const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600); const vBound = (x) => { const t = clamp((x - cx) / half, -1, 1); // まえがみ: a narrow, deep V pointing down the middle (Atom / Mickey). if (shape === 'fringe') return base + 0.15 * Math.max(0, 1 - Math.abs(t) * 1.6); // はちわれ: the same V upside down - a widow's peak. if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5); // カーブ: a deep round cover - low at the sides, high in the middle, like the // blue of a certain robot cat's head. if (shape === 'curve') return base + 0.42 * t * t; // わけめ: a 7:3 part. The hairline is asymmetric across the *front* only and // comes back to the base level at the sides, so it is continuous at the back // seam (an angle-varying hairline there would ride the seam and step). The // parting line is the gap cut out below. if (shape === 'side') { const w = Math.max(0, 1 - t * t); // 0 at the sides, 1 at the front return base + 0.18 * w * (0.5 + 0.5 * t); } return base - 0.07 * (1 - t * t); // `cap` }; const steps = Math.max(96, Math.ceil(W / 2)); ctx.beginPath(); ctx.moveTo(0, 0); for (let i = 0; i <= steps; i += 1) { const x = (W * i) / steps; ctx.lineTo(x, clamp(vBound(x), 0, 1) * H); } ctx.lineTo(W, 0); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); if (shape === 'side') { // The parting: a thin wedge cut out at 30% (a 7:3 split), running from the // top of the head down to the hairline. const partX = span * 0.4; const gw = Math.max(8, span * 0.016) * (0.6 + size); ctx.save(); ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); ctx.moveTo(partX - gw * 0.5, 0); ctx.lineTo(partX + gw * 0.5, 0); ctx.lineTo(partX + gw * 1.2, clamp(vBound(partX), 0, 1) * H + 2); ctx.lineTo(partX - gw * 1.2, clamp(vBound(partX), 0, 1) * H + 2); ctx.closePath(); ctx.fill(); ctx.restore(); } ctx.restore(); } /** * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes, * so the eyes, brows and glasses win any overlap and the cheeks are not doubled. */ function drawFaceExtras(ctx, style, lineOnly) { drawCheeks(ctx, style.cheeks); // The head covering fills out to the plate's real edges, so it needs `limits`. drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS); drawBeard(ctx, style.beard, lineOnly, style.beards); } const BEARD_TINT_IDS = new WeakMap(); const BEARD_TINT_CACHE = new Map(); let beardTintSeq = 0; /** * A copy of a beard drawing in the chosen colour. * * The supplied files are drawn in near-black, so repainting the ink with the * beard colour (`source-in` keeps the anti-aliased alpha) is what lets the colour * picker work on a drawing. Cached per (sprite, colour). */ function tintedSprite(image, color) { let id = BEARD_TINT_IDS.get(image); if (!id) { beardTintSeq += 1; id = beardTintSeq; BEARD_TINT_IDS.set(image, id); } const key = `${id}|${color}`; const cached = BEARD_TINT_CACHE.get(key); if (cached) return cached; const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0); ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = color; ctx.fillRect(0, 0, canvas.width, canvas.height); BEARD_TINT_CACHE.set(key, canvas); return canvas; } /** * ひげ: a mustache or beard under the nose. Each kind is a small shape built on * the spot; `scotch` and `cat` are drawn with round-capped strokes, and `kaiser` * is a thick waving band with a curl at each tip. A kind that ships a drawing in * `assets/beards/` is drawn from that instead (see `textures`), tinted with the * beard colour. In a line drawing nothing is filled and only the outlines are inked. */ function drawBeard(ctx, spec, lineOnly = false, textures = null) { const shape = spec?.shape ?? 'off'; if (shape === 'off' || shape === 'none') return; const size = clamp(spec.size ?? 1, 0.2, 3); const cx = BEARD_LAYOUT.cx; const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600); const w = BEARD_LAYOUT.width * size; const color = spec.color ?? '#3a2a4a'; // A supplied drawing for this kind (assets/beards/.png) replaces the // built-in strokes. The drawings are trimmed to their ink, so `w` is the // beard's real width; it is centred on the nose and scaled by `size`, and it is // tinted with the beard colour (the files are drawn in near-black). const entry = textures?.[shape]; if (entry?.image) { const sprite = tintedSprite(entry.image, color); // Slightly wider than the built-in base: a moustache drawn to the same width // as a beard's stroke sat mostly behind the nose. const drawW = w * 1.4; if (entry.half) { // One side, mirrored to make the pair. The drawing is the model's own right // side, which sits in the artwork's left half; `spacing` opens the middle // (a negative value brings the two halves together). const halfW = drawW / 2; const dh = (halfW * (sprite.height || 1)) / (sprite.width || 1); const gap = clamp(spec.spacing ?? 0, -400, 800); const y = cy - dh / 2; ctx.drawImage(sprite, cx - gap - halfW, y, halfW, dh); ctx.save(); ctx.translate(cx + gap, cy); ctx.scale(-1, 1); // Draw into [-halfW, 0] so that, mirrored, it lands in [cx+gap, cx+gap+halfW]. ctx.drawImage(sprite, -halfW, -dh / 2, halfW, dh); ctx.restore(); return; } const dh = (drawW * (sprite.height || 1)) / (sprite.width || 1); ctx.drawImage(sprite, cx - drawW / 2, cy - dh / 2, drawW, dh); return; } const line = spec.line ?? '#55386e'; const lw = Math.max(2, 9 * size); const stroke = (points, width) => strokePolyline(ctx, points, { color: lineOnly ? line : color, width, }); if (shape === 'scotch') { // ちょび髭: two short dashes under the nose, angled down and out so their tips // clear the nose mesh (which hides anything drawn straight behind it). stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8); stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8); return; } if (shape === 'kaiser') { // カイゼル: a full, thick mustache whose ends turn up. stroke([ { x: cx - w * 0.5, y: cy - w * 0.02 }, { x: cx - w * 0.28, y: cy + w * 0.07 }, { x: cx, y: cy + w * 0.09 }, { x: cx + w * 0.28, y: cy + w * 0.07 }, { x: cx + w * 0.5, y: cy - w * 0.02 }, ], lw * 2.6); const curlAt = (dir) => { // A little inward-turning spiral at the tip: the Dalí curl. const ex = cx + dir * w * 0.5; const ey = cy - w * 0.02; const r0 = lw * 0.5; const r1 = w * 0.17; const steps = 22; const points = []; for (let i = 0; i <= steps; i += 1) { const t = i / steps; const a = -Math.PI / 2 + dir * Math.PI * 2 * 1.4 * t; const r = r0 + (r1 - r0) * t; points.push({ x: ex + dir * Math.cos(a) * r, y: ey + Math.sin(a) * r }); } stroke(points, lw * 1.25); }; curlAt(-1); curlAt(1); return; } if (shape === 'apron') { // A filled bib was once a kind here; kept as a no-op so a saved state that // still names it simply shows nothing rather than throwing. return; } // ねこ: three whiskers a side, growing out of the cheeks with a gap left in the // middle (like a cat's). The thickness is fixed, so `size` makes the whiskers // longer and wider-spread without making them fatter, and `spacing` opens the // gap between the left and right. const whisker = 8; const spacing = clamp(spec.spacing ?? 0, 0, 800); const inner = w * 0.30 + spacing; // `length` scales how far each whisker reaches out; `lineGap` opens the spacing // between the three lines of one side. Neither changes the line's thickness. const length = clamp(spec.length ?? 1, 0.2, 3); const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3); const outer = inner + w * 0.38 * length; for (const dir of [-1, 1]) { for (let i = 0; i < 3; i += 1) { stroke([ { x: cx + dir * inner, y: cy + (i - 1) * gap }, { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 }, ], whisker); } } } /** * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its * temple. * * Called once per eye from `drawEyes`, after the eyeball (or the shut-eye artwork) * so the lens sits in front of the eye, and before that eye's tear so a teardrop * falls past the lens. Nothing about a pair of glasses is per-eye, so the two * halves are mirrored from `eye.towardNose` and meet at the face's midline. The * kind picks the shape: a round 眼鏡 lens, or a wide angular サングラス one. * * `style.glasses` is the shared spec. In a line drawing the lens is left empty, so * the paper behind shows through exactly as the eyeball's white does (see the note * in `drawEyes`); the frames are then stroked in the line colour. * * @param {CanvasRenderingContext2D} ctx * @param {{cx:number,cy:number,r:number,towardNose:number}} eye * @param {boolean} line true in 線画 mode * @param {object} style the bag `drawEyes` received * @param {{top:number,bottom:number,left:number,right:number}} limits */ function drawGlasses(ctx, eye, line, style, limits) { const spec = style.glasses; if (!spec?.enabled) return; const r = eye.r; const scale = clamp(spec.scale ?? 1, 0.2, 3); // The two kinds share everything about *where* they sit but not their shape: // 眼鏡 is a round lens, サングラス a wide angular one (see the layouts above). const shades = spec.kind === 'sunglasses'; const L = shades ? SUNGLASSES_LAYOUT : GLASSES_LAYOUT; const rx = r * L.widthFactor * scale; const ry = rx * L.heightFactor; const frame = Math.max( 1, (spec.frameWidth ?? 1) * EYE_LAYOUT.lidStroke * (shades ? L.frameFactor : 1), ); const colour = line ? (style.line ?? '#55386e') : (spec.frameColor ?? '#2a1e33'); // The outward direction (towards the temple) for this eye. The angular lens is // built in a local frame whose u runs outwards, so the two sides mirror exactly. const out = -eye.towardNose; // The pointed outer corner makes the shades taller on that side; clamp against // the full height so even that point cannot reach the canvas border. const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry; // Keep the lens (and so everything that hangs off it) inside the artwork. The // rest of the file clamps against `limits` for the same reason: a mark that // reaches the canvas border gets the edge row copied across the whole plate. const lensCx = clamp(eye.cx, limits.left + EDGE_MARGIN + rx, limits.right - EDGE_MARGIN - rx); const lensCy = clamp( eye.cy + L.drop + (spec.offsetY ?? 0), limits.top + EDGE_MARGIN + halfV, limits.bottom - EDGE_MARGIN - halfV, ); // The face's midline: the two eyes are symmetric about it, so it is where the // two halves of the bridge meet and what `tilt` turns the pair about. const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2; // Local (u, v) -> canvas, with u outwards and v downwards. const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v }); ctx.save(); // `tilt` leans the whole pair at once. Rotating about the midline keeps the // bridge centred between the lenses instead of swinging it off to one side. if (spec.tilt) { ctx.translate(midX, lensCy); ctx.rotate((clamp(spec.tilt, -90, 90) * Math.PI) / 180); ctx.translate(-midX, -lensCy); } // --- lens ------------------------------------------------------------ // The one path is filled and then stroked, so the fill and the frame can never // drift apart. if (shades) { // A long cat-eye, not an ellipse: the outer end starts low, rises to a // pointed, lifted outer corner set further out, and the top edge sweeps back // towards the nose. The inner end is short, so the lens tapers inwards. const lens = [ at(-rx, -ry * L.innerTop), // inner top (near the nose) at(rx * L.tipX, -ry * (L.innerTop + L.topSkew)), // pointed, lifted outer corner at(rx * L.outerEndX, ry * L.outerBottom), // low outer end at(-rx, ry * L.innerBottom), // inner bottom (pinched towards the nose) ]; traceRoundedPolygon(ctx, lens, ry * L.corner); if (!line) { ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1); ctx.fillStyle = spec.lensColor ?? '#2b2433'; ctx.fill(); ctx.globalAlpha = 1; } ctx.strokeStyle = colour; ctx.lineWidth = frame; ctx.stroke(); } else { ctx.beginPath(); ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU); ctx.closePath(); if (!line) { ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1); ctx.fillStyle = spec.lensColor ?? '#2b2433'; ctx.fill(); ctx.globalAlpha = 1; } ctx.strokeStyle = colour; ctx.lineWidth = frame; ctx.stroke(); } // --- bridge ---------------------------------------------------------- // Each eye draws the half of the bridge from its own lens to the midline. const innerX = eye.towardNose > 0 ? Math.min(lensCx + rx, midX) : Math.max(lensCx - rx, midX); if (shades) { // A short, thick, level bar high on the lenses: with the two lenses it reads // as one continuous visor across the eyes. const barY = lensCy - ry * L.bridgeLift; strokePolyline(ctx, [{ x: innerX, y: barY }, { x: midX, y: barY }], { color: colour, width: frame * L.bridgeWidth, }); } else { // The two halves meet at the midline with a horizontal tangent, so the join is smooth. const noseX = lensCy - L.bridgeRise; strokePolyline(ctx, quadraticPoints( { x: innerX, y: lensCy }, { x: (innerX + midX) / 2, y: noseX }, { x: midX, y: noseX }, 12, ), { color: colour, width: frame * L.bridgeWidth }); } // --- temple ---------------------------------------------------------- // A stub outwards from the outer edge of the lens. It stays short on purpose: // the plate only reaches so far, and a temple that ran off it would smear. The // round pair's outer edge is a vertical line at the lens centre height. The // shades' outer end drops low, so their arm leaves from the *top* edge instead // - from the pointed outer corner - which is where a cat-eye's arm attaches. const outerX = shades ? lensCx - eye.towardNose * rx * L.tipX : lensCx - eye.towardNose * rx; const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy; const reach = clamp( outerX - eye.towardNose * r * L.templeLength, limits.left + EDGE_MARGIN, limits.right - EDGE_MARGIN, ); strokePolyline(ctx, [ { x: outerX, y: outerY }, { x: eye.towardNose > 0 ? Math.min(reach, outerX) : Math.max(reach, outerX), y: clamp(outerY - L.templeRise, limits.top + EDGE_MARGIN, limits.bottom - EDGE_MARGIN), }, ], { color: colour, width: frame }); ctx.restore(); } /** * Draw the pair of eyes into a 1024 x 380 canvas. * * @param {CanvasRenderingContext2D} ctx * @param {object} p * @param {'paint'|'line'} [p.mode] paint = filled cartoon eyes, line = outline only * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }` * @param {object} [p.style] colours and shared shaping parameters */ export function drawEyes(ctx, p) { const mode = p.mode ?? 'paint'; const line = mode === 'line'; const style = p.style ?? {}; const limits = style.limits ?? WINDOW_LIMITS; const white = style.white ?? '#ffffff'; const irisColor = style.iris ?? '#150e1b'; const lineColor = style.line ?? '#55386e'; const lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke; const irisScale = style.irisScale ?? 1; const lookMax = style.lookMax ?? 1; const highlightOn = style.highlight !== false; const heartScale = style.heartScale ?? 0.85; const heartColor = style.heartColor ?? '#e0344f'; const irisRadius = EYE_LAYOUT.irisRadius * irisScale; const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius); const heartTravel = EYE_LAYOUT.radius * 0.9; const lowerLid = clamp(style.lowerLid ?? 0, 0, 1); const lidFlat = (style.lidShape ?? 'curve') === 'flat'; // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. drawFaceExtras(ctx, style, line); for (const layout of EYE_LAYOUT.eyes) { const spec = (p.eyes ?? {})[layout.key] ?? {}; const open = clamp(spec.open ?? 1, 0, 1); const eye = { // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own. cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400), cy: layout.cy, r: EYE_LAYOUT.radius, towardNose: layout.towardNose, }; const lineStyle = { line: lineColor, width: lidWidth }; const isHeart = (spec.irisShape ?? 'circle') === 'heart'; drawBrow(ctx, eye, spec, style, limits); // --- tear ---------------------------------------------------------- // A teardrop hangs below the eye, so it is drawn on top of everything else // (the opaque eyeball would otherwise cover it) and for a shut eye too. The // amount, the height and the tilt are all *per eye*, because one eye crying // while the other does not is a real expression. const drawTear = () => { const amount = clamp(spec.tear ?? 0, 0, 1.6); if (amount <= 0.01) return; const size = EYE_LAYOUT.tear.size * amount; const tearX = eye.cx + clamp(spec.tearX ?? 0, -400, 400) - layout.towardNose * EYE_LAYOUT.tear.offsetX; let tearY = eye.cy + EYE_LAYOUT.tear.offsetY + (spec.tearY ?? 0); // The drop hangs from `cy - 1.32r` to `cy + 0.78r`. Keep its lower tip // inside the artwork: a drop that reached the canvas border used to be // smeared down the chin by the clamp. const tearBottom = limits.bottom - EDGE_MARGIN; if (tearY + size * 0.78 > tearBottom) tearY = tearBottom - size * 0.78; ctx.save(); if (spec.tearTilt) { ctx.translate(tearX, tearY); ctx.rotate((spec.tearTilt * Math.PI) / 180); ctx.translate(-tearX, -tearY); } dropPath(ctx, tearX, tearY, size); if (!line) { ctx.fillStyle = style.tearColor ?? '#8fd8ff'; ctx.fill(); } ctx.strokeStyle = lineColor; ctx.lineWidth = lidWidth * 0.55; ctx.stroke(); ctx.restore(); }; if (open <= 0.02) { drawShutEye(ctx, eye, spec, lineStyle); // The glasses sit over the shut-eye artwork too, but under the tear below. drawGlasses(ctx, eye, line, style, limits); drawTear(); continue; } // --- eyeball ------------------------------------------------------- ctx.save(); // A heart is not an eyeball, so once the eye is fully open it is not clipped // to the eyeball circle - that is what lets a heart be much larger than the // iris. While the lid is partly closed the clip is still needed. if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open), lidFlat); const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel; const dist = Math.hypot(dx, dy); if (dist > eyeTravel && dist > 0) { dx = (dx / dist) * eyeTravel; dy = (dy / dist) * eyeTravel; } // The eyeball's white is only painted in the shaded styles: in line art the // paper behind shows through, which is what a line drawing wants. (The SVG // export traces the alpha mask, so a white fill would close the eye up // completely.) if (!line && !(isHeart && style.heartHideWhite)) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); const irisX = eye.cx + dx; const irisY = eye.cy + dy; if (isHeart) { // A heart is wider than a circle, so cap it at the plane rather than the // eyeball; a red heart has no pupil highlight (it would sit on top of it). // A heart is not an eyeball, so it may grow well past the eye: the plate // keeps the whole canvas, and the eyes plate is drawn over the mouth, so a // big heart has room. The cap only stops it from running off the canvas. const heartRadius = Math.min(irisRadius * heartScale, EYE_LAYOUT.radius * 2.1); fillHeart(ctx, irisX, irisY, heartRadius, line ? lineColor : heartColor); } else { fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor); } if (highlightOn && !isHeart) { const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * irisScale; const hy = irisY + EYE_LAYOUT.highlightOffset.y * irisScale; const hr = EYE_LAYOUT.highlightRadius * irisScale; if (line) { // Punch the sparkle out of the iris rather than painting it white. On // screen the paper behind shows through, and in the SVG export (which // only sees alpha) it stays a hole in the pupil - both of which read as // the white glint of the original artwork. ctx.save(); ctx.globalCompositeOperation = 'destination-out'; fillCircle(ctx, hx, hy, hr, '#000000'); ctx.restore(); } else { fillCircle(ctx, hx, hy, hr, white); } } ctx.restore(); // Between the eyeball and the tear: the lens covers the eye, and a tear still // falls in front of it. drawGlasses(ctx, eye, line, style, limits); drawTear(); // --- lid strokes --------------------------------------------------- // Faded in as the lid starts to cover the eye, so opening the eye all the // way leaves the clean original artwork with no extra line. const lidFade = clamp((0.95 - open) / 0.1, 0, 1); if (lidFade > 0.01 || lowerLid > 0.01) { ctx.save(); circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); ctx.globalAlpha = line ? 1 : lidFade; ctx.strokeStyle = lineColor; ctx.lineWidth = lidWidth; if (lidFade > 0.01) { lidPath(ctx, eye, open, false, lidFlat); ctx.stroke(); } if (lowerLid > 0.01) { ctx.globalAlpha = line ? 1 : lowerLid; lidPath(ctx, eye, lowerLid, true, lidFlat); ctx.stroke(); } ctx.restore(); } // --- outline (line-art mode only) --------------------------------- if (line) strokeCircle(ctx, eye.cx, eye.cy, eye.r, lineColor, lidWidth * 0.75); } } /* -------------------------------------------------------------------- mouth */ /** * Draw the mouth into a 1024 x 380 canvas. * * @param {CanvasRenderingContext2D} ctx * @param {object} p * @param {'paint'|'line'} [p.mode] */ export function drawMouth(ctx, p) { const mode = p.mode ?? 'paint'; const line = mode === 'line'; const L = MOUTH_LAYOUT; const limits = p.limits ?? WINDOW_LIMITS; // The artwork's real edges, in the artwork's own coordinates, with a margin // kept clear. `top` is negative when the plate reaches above the window, which // is the room a moved mouth has to work with. const edgeLeft = limits.left + EDGE_MARGIN; const edgeRight = limits.right - EDGE_MARGIN; const edgeTop = limits.top + EDGE_MARGIN; const edgeBottom = limits.bottom - EDGE_MARGIN; const openAmount = clamp(p.open ?? 0, 0, 1); const thickness = L.thickness * clamp(p.thickness ?? 1, 0.2, 3); const cornerAmount = clamp(p.corners ?? 1, 0, 1.6); // The corner strokes flick out past the ends of the chord, so the ends cannot // use the whole canvas: a very wide mouth used to smear sideways off the plate. const cornerReach = L.corner.toX * cornerAmount; const halfChord = Math.min( L.halfChord * clamp(p.width ?? 1, 0.2, 1.6), Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX), ); // A negative smile bulges the line upwards, which reads as a frown. const sagRaw = L.sag * clamp(p.smile ?? 1, -0.55, 1.4); // A frown's middle is the highest point of the mouth, and that is exactly // where the nose is in the way, so slide the whole mouth down until the curve // is in the open. The push is worked out from the *base* position and the // height slider is added afterwards: adding it first made the push cancel the // slider exactly, which is why dragging ‟口の高さ” did nothing at all on a // frowning mouth like むっと. const base = L.chordY + Math.max(0, L.noseClear - (L.chordY + sagRaw)); let y0 = base + (p.offsetY ?? 0); // The smile flattens slightly as the mouth opens, so the whole mouth keeps // fitting inside the band the mouth plane actually shows. let sag = sagRaw * (1 - L.openFlatten * openAmount); // Keep the whole mark on the canvas. The corner strokes sit *above* the ends of // the chord, so the allowance is not symmetric - padding the bottom by the // corner's depth used to cost most of the height slider's travel. const cornerRise = L.corner.toY * cornerAmount + thickness; const bottomEdge = y0 + Math.max(0, sag) + thickness; if (bottomEdge > edgeBottom) y0 -= bottomEdge - edgeBottom; const topEdge = y0 + Math.min(0, sag) - cornerRise; if (topEdge < edgeTop) y0 += edgeTop - topEdge; const pivot = { x: L.centreX, y: y0 + sag * 0.5 }; const tilt = p.tilt ?? 0; const strokeColor = line ? (p.line ?? '#3a2a4a') : (p.color ?? '#ff1a44'); const innerColor = p.innerColor ?? '#4a0f1e'; const tongueColor = line ? (p.line ?? '#3a2a4a') : (p.tongueColor ?? '#ff2d2d'); const left = { x: L.centreX - halfChord, y: y0 }; const right = { x: L.centreX + halfChord, y: y0 }; const lip = mouthControls(left, right, sag, L.sagBend); const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96); const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6); // --- a round "O" mouth (surprised, singing) --------------------------- const roundAmount = clamp(p.round ?? 0, 0, 1); if (roundAmount > 0.004) { let ry = L.sag * 0.42 * roundAmount * clamp(p.thickness ?? 1, 0.5, 2); // The nose hides the middle of the upper face, so keep the whole oval below // it and inside the plate. (`L.safeBottom` is only the round mouth's own size // limit; it no longer has to stay clear of the canvas edge.) ry = Math.min(ry, (L.safeBottom - thickness / 2 - L.noseClear) / 2.1); const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 0.8); const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - thickness / 2 - ry * 1.02); if (ry > 5) { ctx.save(); ctx.translate(pivot.x, pivot.y); ctx.rotate((tilt * Math.PI) / 180); ctx.translate(-pivot.x, -pivot.y); ctx.beginPath(); ctx.ellipse(L.centreX, centreY, rx, ry, 0, 0, TAU); if (!line) { ctx.fillStyle = innerColor; ctx.fill(); if (tongueAmount > 0.01) { ctx.beginPath(); ctx.ellipse(L.centreX, centreY + ry * 0.46, rx * 0.52, ry * 0.4, 0, 0, TAU); ctx.fillStyle = tongueColor; ctx.fill(); } } ctx.strokeStyle = strokeColor; ctx.lineWidth = line ? thickness * 0.8 : thickness; ctx.stroke(); ctx.restore(); } return; } // The smile line is the upper lip. Opening the mouth drops the lower jaw // below it, and the room for that is limited by the texture band the mouth // plane shows - otherwise the jaw is silently clipped away. // Everything has to fit in the part of the plane that faces the camera. let rise = L.openRise * openAmount; const budget = edgeBottom - thickness / 2 - y0; if (sag + rise > budget) { rise = Math.max(0, budget - Math.min(sag, budget)); if (sag + rise > budget) sag = Math.max(-80, budget - rise); } const jawCubic = mouthControls(left, right, sag + rise, L.sagBend); const jaw = cubicPoints(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, 96); const jawAt = (t) => cubicPoint(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, t); const lipAt = (t) => cubicPoint(lip.p0, lip.p1, lip.p2, lip.p3, t); const openShape = rotate([...arc, ...jaw.slice(1, -1).reverse()], pivot, tilt); const tonguePos = clamp(p.tonguePos ?? L.tongue.pos, 0.05, 0.95); const tongueWidth = L.tongue.width * tongueAmount; const tongueHeight = L.tongue.height * tongueAmount; /** * The tongue, rising from `curve` to `height` above its middle. * * `pointAt(t)` is that same curve as a function of t, so the tongue's foot can * sit along it without this needing to know what kind of curve it is. */ function drawTongue(pointAt, curve, height) { const arcLength = Math.max(1, chordLength(curve)); const dt = clamp(tongueWidth / 2 / arcLength, 0.01, 0.45); const t0 = clamp(tonguePos - dt, 0, 1); const t1 = clamp(tonguePos + dt, 0, 1); const base = []; for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16)); const mid = pointAt(clamp(tonguePos, 0, 1)); const half = tongueWidth / 2; // The foot runs left to right; the crown comes back right to left, so the two // together are already a closed ring. const footRight = base[base.length - 1]; const footLeft = base[0]; const crown = []; const steps = 26; for (let i = 0; i <= steps; i++) { const f = i / steps; const dx = 1 - 2 * f; const footY = footRight.y + (footLeft.y - footRight.y) * f; crown.push({ x: mid.x + dx * half, y: footY - height * (1 - Math.pow(Math.abs(dx), L.tongue.crown)), }); } const shaped = rotate([...base, ...crown], pivot, tilt); if (line) { strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true }); return; } tracePolyline(ctx, shaped, true); ctx.fillStyle = tongueColor; ctx.fill(); } if (openAmount > 0.004) { if (!line) fillPolygon(ctx, openShape, innerColor); // The tongue rises from the lower lip *into* the mouth, so it has to arrive // with the opening: at a hair's width the jaw curve is a sliver, and the // tongue used to burst straight out of it and sit on the chin. const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1); if (tongueAmount > 0.01 && tongueOpen > 0.01) { ctx.save(); tracePolyline(ctx, openShape, true); ctx.clip(); // Inside an open mouth the tongue sits on the lower jaw. drawTongue(jawAt, jaw, tongueHeight * tongueOpen * (1 + openAmount * 0.3)); ctx.restore(); } // One outline around the whole mouth reads as lips; the smile stroke alone // would leave the lower edge as a bare colour change. strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true }); } else { // Closed lips - but the tongue still pokes over the lip. That is what the // original artwork does (the mouth reads as a smile with the tongue showing, // like ペコちゃん), and it is why a plain line looked wrong there. The lip // line goes on afterwards, so the tongue comes out from under it. if (tongueAmount > 0.01) { drawTongue(lipAt, arc, tongueHeight); } strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness }); } // --- corner marks --------------------------------------------------- if (cornerAmount > 0.01) { const C = L.corner; const cornerAngle = p.cornerAngle ?? 0; for (const [end, side] of [[left, -1], [right, 1]]) { const from = { x: end.x + C.fromX * side * cornerAmount, y: end.y - C.fromY * cornerAmount }; const to = { x: end.x - C.toX * side * cornerAmount, y: end.y - C.toY * cornerAmount }; // A smooth curve, not a three-point kink. `mid` is the control point, so it // is pulled twice as far as the bow should reach; both corners bow the same // way (downwards on screen), which is what the original artwork does. const control = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 + (C.curve ?? 26) * cornerAmount, }; const curve = quadraticPoints(from, control, to, 16); // `cornerAngle` tilts the whole mark around the mouth corner, mirrored so // both sides move together. const shaped = cornerAngle ? rotate(curve, end, cornerAngle * side) : curve; strokePolyline(ctx, rotate(shaped, pivot, tilt), { color: line ? strokeColor : (p.cornerColor ?? '#725497'), // The *length* of the corner mark follows `corners`, but its thickness // does not: `corners: 0.71` is the length that matches the original, and // the original's stroke is the full thickness. width: C.width, }); } } } /** Approximate length of a polyline. */ function chordLength(points) { let total = 0; for (let i = 1; i < points.length; i++) { total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); } return total; }