diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 13 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 44 |
2 files changed, 45 insertions, 12 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 01890b5..f61a865 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -281,6 +281,10 @@ export const MOUTH_LAYOUT = { // the corners, which is what reads as a *gentler* curve at the same depth. sagBend: 0.2, thickness: 21, + // How big the round "O" oval gets at `round: 1`, as a multiple of the smile's + // own sag. The oval's size comes from this and `round` alone: `thickness` is + // only the stroke weight, so 口の太さ and 丸く開く no longer move together. + roundScale: 1.3, // Kept for reference: the 2022 plane only showed these rows. bandTop: 46, bandBottom: 332, @@ -1571,12 +1575,13 @@ export function drawMouth(ctx, p) { // --- a round "O" mouth (surprised, singing) --------------------------- const roundAmount = clamp(p.round ?? 0, 0, 1); if (roundAmount > 0.004) { - let ry = L.sag * 1.05 * roundAmount * clamp(p.thickness ?? 1, 0.5, 2); + let ry = L.sag * 1.05 * L.roundScale * roundAmount; // Keep the oval below the nose and inside the plate, but allow it to grow to - // roughly half the head. - ry = Math.min(ry, (edgeBottom - thickness / 2 - L.noseClear) / 2.1); + // roughly half the head. The stroke's share is a fixed margin, not the live + // `thickness`: otherwise 口の太さ would quietly resize the oval too. + ry = Math.min(ry, (edgeBottom - L.thickness / 2 - L.noseClear) / 2.1); const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 1.4); - const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - thickness / 2 - ry * 1.02); + const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - L.thickness / 2 - ry * 1.02); if (ry > 5) { ctx.save(); ctx.translate(pivot.x, pivot.y); diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 7d6c303..9913982 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -1310,7 +1310,7 @@ export function buildPanel(app, root) { const mouthSliders = { smile: slider({ label: '笑いの深さ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), - round: slider({ label: '丸く開く(Oの口)', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), + round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), length: slider({ label: '口の長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }), thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), @@ -1321,13 +1321,37 @@ export function buildPanel(app, root) { corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }), cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }), }; - const mouthVisible = ['smile', 'round', 'length', 'thickness', 'tilt', 'offsetY']; - for (const key of mouthVisible) faceSection.add(mouthSliders[key].el); - faceSection.add(hint('「口の長さ」は口の横の長さです。「口の幅」は丸い口(O)の横の広がりで、' - + 'こまかい設定にあります。')); - const mouthBox = details(faceSection.body, 'こまかい設定(幅・開き・舌・口角)'); - for (const key of ['width', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el); - mouthBox.add(buttons({ + // 口には「ふつうの口」と「丸く開く(O)」の2つの形があり、使うスライダーが + // まったく違う。形を切り替えると、その形で使うものだけを出す。 + const mouthModeOf = (mouth) => (mouth.round > 0.004 ? 'round' : 'curve'); + const mouthRowKeys = { + curve: ['smile', 'length', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY'], + round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'], + }; + // DOM order is fixed here; the mode only shows or hides rows. + const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY']; + const mouthMode = segmented({ + label: '口の形', + options: [ + { value: 'curve', label: 'ふつうの口' }, + { value: 'round', label: '丸く開く(O)' }, + ], + value: 'curve', + onChange: (shape) => { + if (shape === 'round') { + if (!(state.face.mouth.round > 0.004)) state.face.mouth.round = 0.6; + } else { + state.face.mouth.round = 0; + } + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(mouthMode); + for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el); + faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。' + + 'ふつうの口は笑いの深さと口の開き、丸く開く口(O)はOの大きさで形が決まります。')); + faceSection.add(buttons({ items: [{ id: 'mouthreset', label: '口を初期値に戻す', @@ -2830,6 +2854,10 @@ export function buildPanel(app, root) { mouthToggle.set(state.face.mouth.visible); for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]); for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]); + const mouthShape = mouthModeOf(state.face.mouth); + mouthMode.set(mouthShape); + const shown = new Set(mouthRowKeys[mouthShape]); + for (const key of mouthRowOrder) mouthSliders[key].el.style.display = shown.has(key) ? '' : 'none'; }); syncers.push(() => { |
