diff options
Diffstat (limited to 'public/bluebey-studio/src/faceArt.js')
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 1415 |
1 files changed, 1415 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js new file mode 100644 index 0000000..65205fa --- /dev/null +++ b/public/bluebey-studio/src/faceArt.js @@ -0,0 +1,1415 @@ +/** + * 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.62, // half-height, as a fraction of the half-width + lines: 4, // hatch strokes across the patch + lineWidth: 5, + slant: 58, // hatching angle, degrees +}; + +export const HEAD_MARK_LAYOUT = { + cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline + cy: 92, // up on the forehead, above the brows + size: 46, // nominal radius of a shape + lineWidth: 9, +}; + +/** + * 眼鏡 / サングラス, 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, + frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke + highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width +}; + +/** 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 }, + // The tongue *lolling out* of the mouth (舌を出す). The same super-ellipse as + // the tongue above, but the crown hangs below its foot instead of rising over + // it, so the tip droops past the lip. `pos` is its own, because the in-mouth + // tongue sits off to one side and a lolling tongue reads better centred. + tongueOut: { pos: 0.5, width: 104, height: 118, crown: 2.2 }, + 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) { + 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; + const cy = lowest - rl; + ctx.beginPath(); + 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; + const cy = highest + rl; + ctx.beginPath(); + 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) { + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + if (!lower) { + const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount); + ctx.beginPath(); + ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true); + } else { + const highest = eye.cy + eye.r - 2 * eye.r * amount; + ctx.beginPath(); + 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 the strokes keep a clean edge. + ctx.clip(); + + const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines)); + if (lines > 0) { + const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180; + const dx = Math.cos(angle); + const dy = -Math.sin(angle); + const reach = r * 1.3; + ctx.strokeStyle = spec.hatchColor ?? '#e0708f'; + ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size); + ctx.lineCap = 'round'; + for (let i = 0; i < lines; i += 1) { + // Spread the strokes evenly across the patch. + const t = lines === 1 ? 0 : i / (lines - 1) - 0.5; + const px = cx + t * r * 1.2; + ctx.beginPath(); + ctx.moveTo(px - dx * reach, cy - dy * reach); + ctx.lineTo(px + dx * reach, cy + dy * reach); + ctx.stroke(); + } + } + ctx.restore(); +} + +/** 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); + } +} + +/** + * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of + * shapes on purpose - the point is a generic manga device, not a particular + * character's artwork - and it starts off. + */ +function drawHeadMark(ctx, spec) { + const shape = spec.shape ?? 'off'; + if (shape === 'off' || shape === 'none') return; + const size = clamp(spec.size ?? 1, 0.2, 3); + const s = HEAD_MARK_LAYOUT.size * size; + const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400); + const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400); + + ctx.save(); + ctx.strokeStyle = spec.color ?? '#8a4fe0'; + ctx.fillStyle = spec.color ?? '#8a4fe0'; + ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (shape === 'hook') { + // A short curved hook, like a comma. + ctx.beginPath(); + ctx.moveTo(cx - s * 1.05, cy + s * 0.45); + ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15); + ctx.stroke(); + } else if (shape === 'spiral') { + const steps = 60; + ctx.beginPath(); + for (let i = 0; i <= steps; i += 1) { + const t = i / steps; + const angle = t * TAU * 2.2; + const radius = s * (0.14 + 0.86 * t); + const x = cx + Math.cos(angle) * radius; + const y = cy + Math.sin(angle) * radius; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } else if (shape === 'strokes') { + // Three short strokes standing side by side. + for (let i = -1; i <= 1; i += 1) { + ctx.beginPath(); + ctx.moveTo(cx + i * s * 0.62, cy - s * 0.6); + ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6); + ctx.stroke(); + } + } else if (shape === 'dots') { + const radius = Math.max(1.5, ctx.lineWidth * 0.8); + for (let i = -1; i <= 1; i += 1) { + ctx.beginPath(); + ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU); + ctx.fill(); + } + } + 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) { + drawCheeks(ctx, style.cheeks); + drawHeadMark(ctx, style.headMark); +} + +/** + * 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(); + // A light streak inside the glass, clipped to the lens so it can never spill + // over the frame. Shaded drawings only: 線画's lens is empty paper. + if (!line) { + ctx.save(); + traceRoundedPolygon(ctx, lens, ry * L.corner); + ctx.clip(); + strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], { + color: 'rgba(255, 255, 255, 0.5)', + width: Math.max(2, rx * L.highlightWidth), + }); + ctx.restore(); + } + } 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 sits low, so their stub starts down there instead of + // floating below the lens. + const outerX = shades + ? lensCx - eye.towardNose * rx * L.outerEndX + : lensCx - eye.towardNose * rx; + const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : 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); + + // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here + // rather than inside the per-eye loop below. + drawFaceExtras(ctx, style); + + 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)); + + 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); + ctx.stroke(); + } + if (lowerLid > 0.01) { + ctx.globalAlpha = line ? 1 : lowerLid; + lidPath(ctx, eye, lowerLid, true); + 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); + // How far the tongue hangs *out* of the mouth (below the lip). Separate from + // `tongue`, which is the one that pokes up over the lip of a closed mouth. + const tongueOutAmount = clamp(p.tongueOut ?? 0, 0, 1); + + // --- 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(); + } + if (tongueOutAmount > 0.01) { + // A tongue hanging out below the round mouth. Its foot is a flat line + // inside the lower lip; the tip is clamped to the plate's lower edge. + const drop = Math.min(L.tongueOut.height * tongueOutAmount, Math.max(0, edgeBottom - (centreY + ry * 0.5))); + if (drop > 4) { + const half = (L.tongueOut.width * tongueOutAmount) / 2; + const footY = centreY + ry * 0.5; + ctx.beginPath(); + ctx.moveTo(L.centreX - half, footY); + for (let i = 0; i <= 26; i++) { + const dx = 1 - 2 * (i / 26); + ctx.lineTo(L.centreX + dx * half, footY + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.crown))); + } + ctx.closePath(); + 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(); + } + + /** + * The tongue lolling *out* of the mouth, hanging below `curve`. + * + * The mirror of `drawTongue`: the foot sits along the lip (or the lower jaw + * when the mouth is open) and the crown drops *below* it. The tip is clamped + * against the plate's real lower edge, so a tongue on a low mouth gets shorter + * instead of being smeared off the chin by the texture's clamp. + */ + function drawTongueOut(pointAt, curve, height) { + const half = (L.tongueOut.width * tongueOutAmount) / 2; + const pos = clamp(p.tongueOutPos ?? L.tongueOut.pos, 0.05, 0.95); + const arcLength = Math.max(1, chordLength(curve)); + const dt = clamp(half / arcLength, 0.01, 0.45); + const t0 = clamp(pos - dt, 0, 1); + const t1 = clamp(pos + dt, 0, 1); + const base = []; + for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16)); + const mid = pointAt(clamp(pos, 0, 1)); + const drop = Math.min(height, Math.max(0, edgeBottom - mid.y)); + if (drop < 2) return; + 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 + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.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 (tongueOutAmount > 0.01 && tongueOpen > 0.01) { + // A tongue lolling *out* over the lower jaw is not clipped to the mouth: + // it hangs in front of the chin. Drawn before the outline, which then + // crosses its root. + drawTongueOut(jawAt, jaw, L.tongueOut.height * tongueOutAmount * tongueOpen); + } else 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 out. 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. `tongueOut` + // hangs the tongue the other way, *below* the lip line. The lip line goes on + // afterwards, so the tongue comes out from under it. + if (tongueOutAmount > 0.01) { + drawTongueOut(lipAt, arc, L.tongueOut.height * tongueOutAmount); + } else 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; +} |
