diff options
Diffstat (limited to 'bluebey-studio/src/faceArt.js')
| -rw-r--r-- | bluebey-studio/src/faceArt.js | 576 |
1 files changed, 2 insertions, 574 deletions
diff --git a/bluebey-studio/src/faceArt.js b/bluebey-studio/src/faceArt.js index 8537fd8..4aaa2d6 100644 --- a/bluebey-studio/src/faceArt.js +++ b/bluebey-studio/src/faceArt.js @@ -98,16 +98,6 @@ export const EYE_LAYOUT = { ], 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 }, @@ -137,90 +127,13 @@ export const EYE_LAYOUT = { irisInward: 7, }; -/** - * ほっぺ (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. + * covers. * * 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 @@ -679,489 +592,10 @@ function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity) 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)); - - // A supplied drawing (assets/brows/gol-right.png, the model's right brow only) - // replaces the strokes: like the textured ひげ it is drawn twice, mirrored, at - // `cx`, and tinted with the brow colour. - const entry = style.brows?.gol; - if (brow.image && entry?.image) { - const sprite = tintedSprite(entry.image, brow.color ?? '#2a1e33'); - const sw = sprite.width || 1; - const sh = sprite.height || 1; - const drawW = Math.max(4, length); - const drawH = drawW * (sh / sw); - ctx.save(); - ctx.translate(cx, cy); - // The drawing is the model's right brow, so the other eye gets it mirrored. - ctx.scale(eye.towardNose >= 0 ? 1 : -1, 1); - ctx.drawImage(sprite, -drawW / 2, -drawH / 2, drawW, drawH); - ctx.restore(); - return; - } - 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' || shape === 'side') 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 jagged, saw-toothed hairline. A triangle wave across the front - // gives the teeth; `teeth` is how far they reach down (its own slider), so - // the 大きさ slider only moves the hairline up and down. - if (shape === 'fringe') { - const teeth = 8; - const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle - return base + (spec.teeth ?? 0.12) * 600 * saw; - } - // はちわれ: 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; - // `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(); - 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`, `wave` and `side` are retired front-plate shapes; they have no wrap - // equal. An old saved state may still carry one, so it simply draws no hair. - if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave' || shape === 'side') 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; - 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); - // まえがみ: 富士額 / M字 (a widow's peak). The hairline comes down at the - // centre (the peak) and at the temples, with the corners between receded - - // the three peaks and two valleys of an "M". `t` is -1..1 across the front. - if (shape === 'fringe') { - // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front - // gives the teeth; `teeth` is how far they reach down (its own slider), so - // the 大きさ slider only moves the hairline up and down. - const teeth = 8; - const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle - return base + (spec.teeth ?? 0.12) * saw; - } - // はちわれ: 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; - 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(); - // A white mask: the colour is applied by the hair plate's material, so it goes - // through the same colour path as the body instead of being baked into the - // canvas (which shaded differently on iOS). - ctx.fillStyle = '#ffffff'; - 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, 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/<shape>.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 @@ -1382,14 +816,10 @@ export function drawEyes(ctx, p) { const lensOpaque = style.glasses?.enabled === true && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99; - // ほっぺ 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 eye = { - // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own. + // `eyeX` slides this eye - and its lid and tear - sideways on its own. cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400), cy: layout.cy, r: EYE_LAYOUT.radius, @@ -1436,8 +866,6 @@ export function drawEyes(ctx, p) { // scaled back inside it; `shapeHalfLimit` is the half-size it may reach. const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity; - 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 |
