From 174886a483d90756b1320c12aef0e6f296c6d425 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:13:21 +0900 Subject: bluebey-studio: 目メニューを再編し、閉じ目にもまぶたを効かせる MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 閉じ目を眼球の中身として上まぶた・下まぶたで隠せるようにした。まぶた設定を「まぶた」グループに、ハートの色を目の形の直下に、瞳と形の大きさを「大きさ」に統合。こまかい設定は解体し、横位置・手描きは個別の箱に分けた。 --- public/bluebey-studio/src/faceArt.js | 169 ++++++++++++++++++++--------------- public/bluebey-studio/src/gacha.js | 25 ++++-- public/bluebey-studio/src/panel.js | 120 +++++++++++++------------ public/bluebey-studio/src/presets.js | 14 +-- 4 files changed, 186 insertions(+), 142 deletions(-) (limited to 'public') diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 3eb106f..04dd6e3 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -489,10 +489,8 @@ function rotate(points, pivot, degrees) { * 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, lowerLid, flat = false, tiltDeg = 0) { - circlePath(ctx, eye.cx, eye.cy, eye.r); - ctx.clip(); - +/** Clip to the upper and lower lids only, without the eyeball circle. */ +function clipLids(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180; const rotated = Math.abs(tilt) > 1e-4; const spin = () => { @@ -557,6 +555,13 @@ function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { } } +/** Clip to the eyeball circle as well (the iris and heart are eyeball content). */ +function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { + circlePath(ctx, eye.cx, eye.cy, eye.r); + ctx.clip(); + clipLids(ctx, eye, open, lowerLid, flat, tiltDeg); +} + function lidPath(ctx, eye, amount, lower, flat = false) { ctx.beginPath(); if (flat) { @@ -1316,6 +1321,23 @@ function drawGlasses(ctx, eye, line, style, limits) { * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }` * @param {object} [p.style] colours and shared shaping parameters */ +/** + * Which artwork an eye shows: 'open', 'heart', 'line1'..'line3', 'three' or + * 'arch'. `shape` is the explicit choice; older saved states and the おまかせ + * rolls only set `open`/`closed`/`irisShape`, so this falls back to those. + */ +function eyeShapeKey(spec) { + // A fully lowered upper lid always means a shut artwork, even if a stale + // `shape` says otherwise - this is what keeps older saved links working. + if ((spec.open ?? 1) <= 0.02) { + if (spec.closed === 'three') return 'three'; + if (spec.closed === 'arch') return 'arch'; + return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`; + } + if (spec.shape) return spec.shape; + return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; +} + export function drawEyes(ctx, p) { const mode = p.mode ?? 'paint'; const line = mode === 'line'; @@ -1325,12 +1347,9 @@ export function drawEyes(ctx, p) { 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 heartScale = 2; 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 lidFlat = (style.lidShape ?? 'curve') === 'flat'; // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint). @@ -1342,7 +1361,6 @@ export function drawEyes(ctx, p) { 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), @@ -1351,14 +1369,35 @@ export function drawEyes(ctx, p) { towardNose: layout.towardNose, }; const lineStyle = { line: lineColor, width: lidWidth }; - const isHeart = (spec.irisShape ?? 'circle') === 'heart'; - // The lower lid is per eye (falling back to the shared value). + // Which artwork this eye shows. `shape` is the explicit choice; older saved + // states and the おまかせ rolls only set `open`/`closed`, so fall back. + const shapeKey = eyeShapeKey(spec); + const explicitShut = typeof spec.shape === 'string' + && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line')); + const isHeart = shapeKey === 'heart'; + const isShutShape = shapeKey === 'three' || shapeKey === 'arch' || shapeKey.startsWith('line'); + // The shut artwork reads `closed`/`closedLines`; build them from `shape` so a + // line, the 3 and the arch share one path with the older fields. + const shapeSpec = isShutShape + ? { + ...spec, + closed: shapeKey === 'three' ? 'three' : (shapeKey === 'arch' ? 'arch' : 'line'), + closedLines: shapeKey.startsWith('line') ? Number(shapeKey.slice(4)) : (spec.closedLines ?? 1), + } + : spec; + // The upper lid (`open`) and the lower lid are independent, and both shape + // every eye now: the shut artwork is drawn as the eye's content and is + // trimmed by them. A shut eye saved the old way stored open = 0 meaning + // "shut"; lift it or the artwork would be clipped away. An explicit shut + // `shape` keeps its own lid height, so the lid can be lowered onto it. + const rawOpen = spec.open ?? 1; + const open = clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1); const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1); - // 形の大きさ: scales the drawn shape (shut lines, the 3, the arch, the heart). - const shapeScale = clamp(spec.shapeScale ?? 1, 0.4, 2); - // `white` is tri-state: an open eye shows its white, a shut eye or a heart - // does not, unless the switch forces it one way. - const showWhite = spec.white != null ? spec.white === true : (open > 0.02 && !isHeart); + // 大きさ: one knob for the iris, the heart and the shut artwork. + const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2); + const irisRadius = EYE_LAYOUT.irisRadius * shapeScale; + // `white` is tri-state: only the plain open eye shows it by default. + const showWhite = spec.white != null ? spec.white === true : shapeKey === 'open'; // While the white is shown, a shape that would poke out of the eyeball is // scaled back inside it; `shapeHalfLimit` is the half-size it may reach. const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity; @@ -1399,9 +1438,11 @@ export function drawEyes(ctx, p) { ctx.restore(); }; - // Where the pupil is looking. Every eye shape shares it: a shut eye's line, a - // 3 or an arch shift with the gaze too, not just the round eyeball. - const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; + // Where the pupil is looking. Every eye shape shares it: the shut artwork + // shifts with the gaze too, not just the round eyeball. + const eyeTravel = isHeart + ? heartTravel * lookMax + : Math.max(0, EYE_LAYOUT.radius - irisRadius) * 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); @@ -1410,64 +1451,48 @@ export function drawEyes(ctx, p) { dy = (dy / dist) * eyeTravel; } - if (open <= 0.02 || open <= lowerLid + 0.02) { - // A shut eye can still be given a white backing (the "白目" switch), which - // reads as a line drawn across an eyeball rather than a bare line. This also - // catches a lower lid raised past the upper one, which reads as shut too. - if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); - // 目線 moves the drawn shape as well, so a shut eye can glance sideways. - ctx.save(); - ctx.translate(dx, dy); - drawShutEye(ctx, eye, spec, lineStyle, shapeScale, shapeHalfLimit); - ctx.restore(); - // The glasses sit over the shut-eye artwork too, but under the tear below. - drawGlasses(ctx, eye, line, style, limits); - drawTear(); - continue; - } - - // --- eyeball ------------------------------------------------------- + // --- the eye's content, clipped by the lids ------------------------- + // Every shape is drawn the same way now: a white backing, then the artwork + // (iris, heart or the shut line/3/arch), all trimmed by the upper and lower + // lids. The shut artwork reaches past the eyeball, so it is only lid-clipped. 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, isHeart ? 0 : lowerLid, lidFlat, lidTilt * eye.towardNose); - - // The white of the eye (see `showWhite` above). + if (isShutShape) { + clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); + } else if (!isHeart || open < 0.999) { + clipEye(ctx, eye, open, isHeart ? 0 : lowerLid, lidFlat, lidTilt * eye.towardNose); + } if (!line && showWhite) 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. - // With the white shown, the default size is the one that just fits inside - // the eyeball; 形の大きさ can still grow it past the white. - const heartFit = showWhite ? Math.min(1, shapeHalfLimit / (irisRadius * heartScale)) : 1; - const heartRadius = Math.min(irisRadius * heartScale * shapeScale * heartFit, EYE_LAYOUT.radius * 2.1); - fillHeart(ctx, irisX, irisY, heartRadius, line ? lineColor : heartColor); + if (isShutShape) { + ctx.translate(dx, dy); + drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit); + } else if (isHeart) { + // A heart is not an eyeball, so it may grow past the eye up to the plate. + // With the white shown, its default size is what just fits inside it; the + // single 大きさ knob can still grow it past the white. + const heartFit = showWhite ? Math.min(1, shapeHalfLimit / (EYE_LAYOUT.irisRadius * heartScale)) : 1; + const heartRadius = Math.min(EYE_LAYOUT.irisRadius * heartScale * shapeScale * heartFit, EYE_LAYOUT.radius * 2.1); + fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor); } else { + const irisX = eye.cx + dx; + const irisY = eye.cy + dy; fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor); - } - // 光彩 (the white glint) is per eye, falling back to the shared switch. - const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false; - 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); + // 光彩 (the white glint) is per eye, falling back to the shared switch. + const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false; + if (highlightOn) { + const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * shapeScale; + const hy = irisY + EYE_LAYOUT.highlightOffset.y * shapeScale; + const hr = EYE_LAYOUT.highlightRadius * shapeScale; + 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. + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + fillCircle(ctx, hx, hy, hr, '#000000'); + ctx.restore(); + } else { + fillCircle(ctx, hx, hy, hr, white); + } } } ctx.restore(); diff --git a/public/bluebey-studio/src/gacha.js b/public/bluebey-studio/src/gacha.js index d689fa5..c44f80f 100644 --- a/public/bluebey-studio/src/gacha.js +++ b/public/bluebey-studio/src/gacha.js @@ -237,6 +237,8 @@ function rollEyes(random, spec) { const [openMin, openMax] = spec.open; let leftOpen = round2(rand(random, openMin, openMax)); let rightOpen = round2(clamp(leftOpen + rand(random, -0.06, 0.06), 0, 1)); + let leftShape = 'open'; + let rightShape = 'open'; let leftClosed = 'line'; let rightClosed = 'line'; let leftLines = 1; @@ -245,28 +247,37 @@ function rollEyes(random, spec) { if (spec.wink) { const side = chance(random, 0.5) ? 'left' : 'right'; const lines = pick(random, [2, 3]); + // A shut eye is the artwork with the lids open now; the lid height is its own + // slider, so `open` is 1 and the shape says which line it is. if (side === 'left') { - leftOpen = 0; + leftOpen = 1; leftLines = lines; + leftShape = `line${lines}`; } else { - rightOpen = 0; + rightOpen = 1; rightLines = lines; + rightShape = `line${lines}`; } } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) { - leftOpen = 0; - rightOpen = 0; + leftOpen = 1; + rightOpen = 1; leftClosed = spec.shutBoth.closed; rightClosed = spec.shutBoth.closed; leftLines = spec.shutBoth.closedLines; rightLines = spec.shutBoth.closedLines; + const shape = spec.shutBoth.closed === 'line' + ? `line${spec.shutBoth.closedLines}` + : spec.shutBoth.closed; + leftShape = shape; + rightShape = shape; } const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1])); const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1])); return { - left: { open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' }, - right: { open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' }, + left: { shape: leftShape, open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' }, + right: { shape: rightShape, open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' }, irisScale: round2(rand(random, spec.irisScale[0], spec.irisScale[1])), lookMax: round2(rand(random, spec.lookMax[0], spec.lookMax[1])), highlight: true, @@ -330,6 +341,8 @@ export function randomFace(random) { if (mood.heart && eyes.left.open >= 0.9 && eyes.right.open >= 0.9) { eyes.left.irisShape = 'heart'; eyes.right.irisShape = 'heart'; + eyes.left.shape = 'heart'; + eyes.right.shape = 'heart'; eyes.heartScale = round2(rand(random, 0.9, 1.15)); eyes.heartColor = '#e0344f'; eyes.highlight = false; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index b942c22..bc8e243 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -457,6 +457,7 @@ export function buildPanel(app, root) { state.face.eyes.right.closed = state.face.eyes.left.closed; state.face.eyes.right.closedLines = state.face.eyes.left.closedLines; state.face.eyes.right.irisShape = state.face.eyes.left.irisShape; + state.face.eyes.right.shape = state.face.eyes.left.shape; state.face.eyes.right.white = state.face.eyes.left.white ?? null; state.face.eyes.right.highlight = state.face.eyes.left.highlight ?? null; state.face.eyes.right.lookX = state.face.eyes.left.lookX; @@ -482,15 +483,19 @@ export function buildPanel(app, root) { // The shut line's stroke count is part of the shape itself. return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`; } + if (eye.shape) return eye.shape; return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; }; const applyEyeShape = (eye, value) => { + eye.shape = value; eye.irisShape = value === 'heart' ? 'heart' : 'circle'; if (value === 'open' || value === 'heart') { eye.open = 1; return; } - eye.open = 0; + // A shut artwork is drawn as the eye's content with the lids open; the lid + // height is its own slider (上まぶたの高さ), so selecting one opens the lid. + eye.open = 1; if (value.startsWith('line')) { eye.closed = 'line'; eye.closedLines = Number(value.slice(4)) || 1; @@ -535,21 +540,8 @@ export function buildPanel(app, root) { label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { write((e) => { e.lowerLid = v; }); touched(); }, }); - // まぶたの形 and tilt are whole-face settings (the drawing mirrors the tilt - // between the eyes), so every group shows the same pair, right above 目の形. - const lidShape = segmented({ - label: 'まぶたの形', - options: [ - { value: 'curve', label: '曲線' }, - { value: 'flat', label: '直線(平ら)' }, - ], - value: 'curve', - onChange: (value) => { state.face.eyes.lidShape = value; touched(); }, - }); - const lidTilt = slider({ - label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (value) => { state.face.eyes.lidTilt = value; touched(); }, - }); + // 目の形: the artwork the eye shows. A shut shape is drawn with the lids open, + // so 上まぶたの高さ can then be lowered onto it. const shape = segmented({ label: '目の形', options: [ @@ -564,9 +556,17 @@ export function buildPanel(app, root) { value: 'open', onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); }, }); - const shapeScale = slider({ - label: '形の大きさ', min: 0.5, max: 1.8, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { write((e) => { e.shapeScale = v; }); touched(); }, + // The heart colour only matters for the heart, so it sits right under 目の形 + // and shows only then. + const heartColor = colorField({ + label: 'ハートの色', value: '#e0344f', + swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'], + onChange: (v) => { state.face.eyes.heartColor = v; touched(); }, + }); + // 大きさ: one knob for the iris, the heart and the shut artwork. + const size = slider({ + label: '大きさ', min: 0.4, max: 1.8, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.irisScale = v; touched(); }, }); const threeFlip = segmented({ label: '3の向き', @@ -628,10 +628,9 @@ export function buildPanel(app, root) { group.add(open); group.add(lowerLid); - group.add(lidShape); - group.add(lidTilt); group.add(shape); - group.add(shapeScale); + group.add(heartColor); + group.add(size); group.add(threeFlip); group.add(controlRow(null, white.el, { wide: true })); group.add(controlRow(null, highlight.el, { wide: true })); @@ -648,10 +647,10 @@ export function buildPanel(app, root) { const shapeValue = eyeShapeOf(e); open.set(e.open); lowerLid.set(e.lowerLid ?? state.face.eyes.lowerLid ?? 0); - lidShape.set(state.face.eyes.lidShape ?? 'curve'); - lidTilt.set(state.face.eyes.lidTilt ?? 0); shape.set(shapeValue); - shapeScale.set(e.shapeScale ?? 1); + heartColor.set(state.face.eyes.heartColor ?? '#e0344f'); + heartColor.el.style.display = shapeValue === 'heart' ? '' : 'none'; + size.set(state.face.eyes.irisScale ?? 1); threeFlip.set(e.threeFlip ? 'flip' : 'normal'); threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none'; white.set(eyeWhiteOf(e)); @@ -674,15 +673,40 @@ export function buildPanel(app, root) { const leftControls = makeEyeControls('left'); const rightControls = makeEyeControls('right'); + // --- まぶた (whole-face lid settings, shown above the per-eye groups) ------- + const lidGroup = eyeGroup('まぶた'); + const lidShapeSegment = segmented({ + label: 'まぶたの形', + options: [ + { value: 'curve', label: '曲線' }, + { value: 'flat', label: '直線(平ら)' }, + ], + value: 'curve', + onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); }, + }); + const lidWidthSlider = slider({ + label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`, + onInput: (value) => { state.face.eyes.lidWidth = value; app.actions.refresh('face'); }, + }); + const lidTiltSlider = slider({ + label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (value) => { state.face.eyes.lidTilt = value; app.actions.refresh('face'); }, + }); + lidGroup.add(lidShapeSegment.el); + lidGroup.add(lidWidthSlider.el); + lidGroup.add(lidTiltSlider.el); + lidGroup.add(hint('「直線(平ら)」にすると、まぶたが平らに下りてきます。' + + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。')); + faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); + faceSection.add(lidGroup); faceSection.add(bothControls.group); faceSection.add(leftControls.group); faceSection.add(rightControls.group); - faceSection.add(hint('「まぶたの形」を「直線(平ら)」にすると、まぶたが平らに下りてきます。' - + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。' - + '「目の形」の「2線」「3線」は、端点がつながった形になります。')); + faceSection.add(hint('「目の形」の「2線」「3線」は端点がつながった形になります。' + + '閉じ目の形も、上まぶた・下まぶたで上下から隠せます。')); - // 横位置 (per eye) rarely changes, so it lives in こまかい設定. + // 横位置 (per eye) rarely changes, so it gets its own small box. const eyeAdvancedWidgets = {}; for (const key of ['left', 'right']) { const label = key === 'left' ? '左の目' : '右の目'; @@ -710,29 +734,9 @@ export function buildPanel(app, root) { }], })); - faceSection.add(hint('「左右を連動させる」を入れると「両目」の設定が1つだけ出て、上まぶた・下まぶた・まぶたの形・形・白目・光彩・目線が両目でそろいます。' + faceSection.add(hint('「左右を連動させる」を入れると「両目」の設定が1つだけ出て、上まぶた・下まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。' + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。')); - const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・色・まぶたの太さ・左右べつの設定・手描き)'); - const irisScaleSlider = slider({ - label: '瞳の大きさ', min: 0.4, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (value) => { state.face.eyes.irisScale = value; app.actions.refresh('face'); }, - }); - const heartColorField = colorField({ - label: 'ハートの色', value: '#e0344f', - swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'], - onChange: (v) => { state.face.eyes.heartColor = v; app.actions.refresh('face'); }, - }); - const lidWidthSlider = slider({ - label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`, - onInput: (value) => { state.face.eyes.lidWidth = value; app.actions.refresh('face'); }, - }); - eyeAdvanced.add(irisScaleSlider.el); - eyeAdvanced.add(heartColorField.el); - eyeAdvanced.add(lidWidthSlider.el); - eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「白目」「光彩」は、この上の「目」にあります。' - + 'ハートの大きさは「瞳の大きさ」で変わります。')); - const eyeColors = { white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }), @@ -741,8 +745,8 @@ export function buildPanel(app, root) { const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)'); for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el); - // 横位置 (per eye) lives here with the other extras. - const tearBox = details(eyeAdvanced.body, '横位置(左右べつ)'); + // 横位置 (per eye) is rarely used, so it stays in its own small box. + const tearBox = details(faceSection.body, '横位置(左右べつ)'); for (const el of tearWidgets) tearBox.add(el); const eyeSource = selectField({ @@ -756,7 +760,9 @@ export function buildPanel(app, root) { value: 'parametric', onChange: (value) => { state.face.eyes.source = value; app.actions.refresh('face'); }, }); - eyeAdvanced.add(eyeSource.el); + // 手描きの目(パーツの選択と読み込み)は、大きさやまぶたとは別の箱にまとめる。 + const eyeArtBox = details(faceSection.body, '手描きの目(パーツ・読み込み)'); + eyeArtBox.add(eyeSource.el); // 手描き画像の読み込み(GIMP などで描いた PNG をそのまま使えます) const rebuildEyeOptions = () => { @@ -793,8 +799,8 @@ export function buildPanel(app, root) { toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); } }); - eyeAdvanced.add(eyeImagePicker); - eyeAdvanced.add(buttons({ + eyeArtBox.add(eyeImagePicker); + eyeArtBox.add(buttons({ items: [{ id: 'eyeimg', label: '目の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) eyeImagePicker.click(); } }], })); @@ -2760,9 +2766,9 @@ export function buildPanel(app, root) { w.eyeX.set(eye.eyeX ?? 0); } - irisScaleSlider.set(state.face.eyes.irisScale); - heartColorField.set(state.face.eyes.heartColor ?? '#e0344f'); lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); + lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve'); + lidTiltSlider.set(state.face.eyes.lidTilt ?? 0); browToggle.set(state.face.eyes.brow.enabled); browSliders.angle.set(state.face.eyes.brow.angle); browSliders.height.set(state.face.eyes.brow.height); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index ca8f048..a8b10ec 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -200,8 +200,8 @@ export function defaultState() { eyes: { source: 'parametric', // parametric | opened | closed | ... (hand-drawn) linked: true, // move both eyes together - left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null }, - right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null }, + left: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null }, + right: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null }, irisScale: 1, lookMax: 1.2, highlight: true, @@ -407,8 +407,8 @@ export const FACE_PRESETS = [ label: 'にっこり', face: { eyes: { - left: { open: 0, lookX: 0, lookY: 0, closed: 'arch' }, - right: { open: 0, lookX: 0, lookY: 0, closed: 'arch' }, + left: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' }, + right: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' }, highlight: false, }, // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a @@ -437,7 +437,7 @@ export const FACE_PRESETS = [ eyes: { // The shut eye is the original `eyes-close-tight` artwork: three strokes // sharing one vertex, pointing at the nose. - left: { open: 0, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 }, + left: { shape: 'line3', open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 }, right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 }, irisScale: 1, highlight: true, @@ -513,8 +513,8 @@ export const FACE_PRESETS = [ label: 'すき', face: { eyes: { - left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, - right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + left: { shape: 'heart', open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + right: { shape: 'heart', open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, irisScale: 1, // The white behind a heart would poke out around its lobes, so hide it. heartColor: '#e0344f', -- cgit v1.3.1