diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 14:29:18 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 14:29:18 +0900 |
| commit | 88010ce24f3a863518071c4bd5dd5d351d1bad26 (patch) | |
| tree | c8a584defd55cdd3cb6763716c8899c7a1d695db /public | |
| parent | 8598b20ff6ebd68bf139fc361d5b311f7a553882 (diff) | |
bluebey-studio: まぶたを左右の目で個別設定に、目グループにアイコン追加
まぶた(形・太さ・傾き・まつげ・上下の高さ)を目のグループ内に移し、左右別々に設定できるようにした。上まぶたと下まぶたが交差しないようスライダー範囲を相互に制限。両目・左の目・右の目に見分け用アイコンを付けた。
Diffstat (limited to 'public')
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 11 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 5 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 149 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 4 | ||||
| -rw-r--r-- | public/bluebey-studio/src/style.css | 4 | ||||
| -rw-r--r-- | public/bluebey-studio/src/ui.js | 20 |
6 files changed, 125 insertions, 68 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 765c0db..aa1204b 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -1348,14 +1348,10 @@ export function drawEyes(ctx, p) { const white = style.white ?? '#ffffff'; const irisColor = style.iris ?? '#150e1b'; const lineColor = style.line ?? '#55386e'; - const lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke; const lookMax = style.lookMax ?? 1; const heartScale = 2; const heartColor = style.heartColor ?? '#e0344f'; 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). - const lidTilt = clamp(style.lidTilt ?? 0, -45, 45); // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. @@ -1370,6 +1366,11 @@ export function drawEyes(ctx, p) { r: EYE_LAYOUT.radius, towardNose: layout.towardNose, }; + // Each lid can be set per eye, falling back to the shared value. + const lidWidth = spec.lidWidth ?? style.lidWidth ?? EYE_LAYOUT.lidStroke; + const lidFlat = (spec.lidShape ?? style.lidShape ?? 'curve') === 'flat'; + // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint). + const lidTilt = clamp(spec.lidTilt ?? style.lidTilt ?? 0, -45, 45); const lineStyle = { line: lineColor, width: lidWidth }; // Which artwork this eye shows. `shape` is the explicit choice; older saved // states and the おまかせ rolls only set `open`/`closed`, so fall back. @@ -1545,7 +1546,7 @@ export function drawEyes(ctx, p) { // --- まつげ -------------------------------------------------------- // Short strokes flicking outwards from the upper-outer lid. They sit outside // the eyeball, so they are drawn after the lid block and are not clipped. - if (style.lashes) { + if (spec.lashes ?? style.lashes) { const outDir = -eye.towardNose; for (const deg of EYE_LAYOUT.lash.angles) { const t = (deg * Math.PI) / 180; diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index d4ef4f2..7fdc97f 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -1655,6 +1655,11 @@ async function init() { tearTilt: own.tearTilt ?? 0, // The lower lid can differ per eye (falling back to the shared value). lowerLid: own.lowerLid ?? eyes.lowerLid ?? 0, + // The rest of the lids are per eye too now (null = use the shared value). + lidShape: own.lidShape ?? null, + lidWidth: own.lidWidth ?? null, + lidTilt: own.lidTilt ?? null, + lashes: own.lashes ?? null, }; }; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index f377d16..b5e4e6e 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -1,6 +1,6 @@ import { h, section as uiSection, subhead, hint, slider, check, segmented, buttons, - colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, + colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, } from './ui.js'; import { FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, @@ -458,6 +458,12 @@ export function buildPanel(app, root) { 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; + // The lids are per eye now too, so link them along with the rest. + state.face.eyes.right.lowerLid = state.face.eyes.left.lowerLid; + state.face.eyes.right.lidShape = state.face.eyes.left.lidShape; + state.face.eyes.right.lidWidth = state.face.eyes.left.lidWidth; + state.face.eyes.right.lidTilt = state.face.eyes.left.lidTilt; + state.face.eyes.right.lashes = state.face.eyes.left.lashes; 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; @@ -512,11 +518,20 @@ export function buildPanel(app, root) { ? eye.highlight === true : state.face.eyes.highlight !== false); + const EYE_ICONS = { + both: '<circle cx="7" cy="12" r="3.8"/><circle cx="17" cy="12" r="3.8"/><circle cx="7" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + left: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + right: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + lid: '<path d="M3 14c2.5-6.5 15.5-6.5 18 0"/><path d="M6 16v3M12 16v3M18 16v3"/>', + }; // Each eye group carries the same controls. Linked, one group ("両目") drives // both eyes; unlinked, a group per eye appears and the gaze pad doubles. - const eyeGroup = (title) => { + const eyeGroup = (title, icon = null) => { const body = h('div', { class: 'eye-group-body' }); - const el = h('div', { class: 'eye-group' }, h('div', { class: 'eye-group-head', text: title }), body); + const head = h('div', { class: 'eye-group-head' }, + icon ? svgIcon(EYE_ICONS[icon] ?? '') : null, + h('span', { text: title })); + const el = h('div', { class: 'eye-group' }, head, body); return { el, add: (child) => (body.append(child?.el ?? child), child), @@ -529,7 +544,65 @@ export function buildPanel(app, root) { const primary = keys[0]; const write = (fn) => { for (const k of keys) fn(state.face.eyes[k]); }; const touched = () => { app.actions.refresh('face'); sync(); }; - const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左の目' : '右の目')); + const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左の目' : '右の目'), mode); + + // --- まぶた (per eye) ------------------------------------------------ + // 上まぶたの高さ and 下まぶたの高さ share one opening, so each is held to the + // other: the lower lid cannot rise past the upper, and the upper cannot drop + // below the lower. The sliders' own limits are kept in step in `syncOne`. + const lidSub = eyeGroup('まぶた', 'lid'); + const open = slider({ + label: '上まぶたの高さ', min: 0, max: 1, step: 0.01, value: 1, + format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`), + onInput: (v) => { + const floor = state.face.eyes[primary].lowerLid ?? state.face.eyes.lowerLid ?? 0; + const vv = Math.max(v, floor); + write((e) => { e.open = vv; }); + if (vv !== v) open.set(vv); + touched(); + }, + }); + const lowerLid = slider({ + label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { + const ceil = state.face.eyes[primary].open ?? 1; + const vv = Math.min(v, ceil); + write((e) => { e.lowerLid = vv; }); + if (vv !== v) lowerLid.set(vv); + touched(); + }, + }); + const lidShape = segmented({ + label: 'まぶたの形', + options: [ + { value: 'curve', label: '曲線' }, + { value: 'flat', label: '直線(平ら)' }, + ], + value: 'curve', + onChange: (v) => { write((e) => { e.lidShape = v; }); touched(); }, + }); + const lidWidth = slider({ + label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`, + onInput: (v) => { write((e) => { e.lidWidth = v; }); touched(); }, + }); + const lidTilt = slider({ + label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { write((e) => { e.lidTilt = v; }); touched(); }, + }); + const lashes = check({ + label: 'まつげをつける', + value: false, + onChange: (v) => { write((e) => { e.lashes = v; }); touched(); }, + }); + lidSub.add(lidShape); + lidSub.add(open); + lidSub.add(lowerLid); + lidSub.add(lidWidth); + lidSub.add(lidTilt); + lidSub.add(controlRow(null, lashes.el, { wide: true })); + group.add(lidSub); + const openInput = open.el.querySelector('input'); + const lowerLidInput = lowerLid.el.querySelector('input'); // 目の形: the artwork the eye shows. A shut shape is drawn with the lids open, // so 上まぶたの高さ can then be lowered onto it. @@ -634,6 +707,18 @@ export function buildPanel(app, root) { const syncOne = () => { const e = state.face.eyes[primary]; const shapeValue = eyeShapeOf(e); + const lower = e.lowerLid ?? state.face.eyes.lowerLid ?? 0; + const upper = e.open ?? 1; + // Keep each lid's slider range from crossing the other before applying the + // values, so the displayed thumb cannot sit past the other lid either. + if (openInput) openInput.min = String(Math.min(lower, upper)); + if (lowerLidInput) lowerLidInput.max = String(Math.max(lower, upper)); + open.set(upper); + lowerLid.set(lower); + lidShape.set(e.lidShape ?? state.face.eyes.lidShape ?? 'curve'); + lidWidth.set(e.lidWidth ?? state.face.eyes.lidWidth ?? 14); + lidTilt.set(e.lidTilt ?? state.face.eyes.lidTilt ?? 0); + lashes.set((e.lashes ?? state.face.eyes.lashes) === true); shape.set(shapeValue); heartColor.set(state.face.eyes.heartColor ?? '#e0344f'); heartColor.el.style.display = shapeValue === 'heart' ? '' : 'none'; @@ -660,53 +745,7 @@ 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'); }, - }); - // The lid heights write both eyes together: they live in the まぶた group, which - // is shared, while 目の形 and the rest stay per eye below. - const setBothEyes = (patch) => { for (const k of ['left', 'right']) Object.assign(state.face.eyes[k], patch); }; - const lidOpenSlider = slider({ - label: '上まぶたの高さ', min: 0, max: 1, step: 0.01, value: 1, - format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`), - onInput: (v) => { setBothEyes({ open: v }); app.actions.refresh('face'); }, - }); - const lidLowerSlider = slider({ - label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { setBothEyes({ lowerLid: 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'); }, - }); - 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'); }, - }); - const lashesToggle = check({ - label: 'まつげをつける', - value: false, - onChange: (v) => { state.face.eyes.lashes = v; app.actions.refresh('face'); }, - }); - lidGroup.add(lidShapeSegment.el); - lidGroup.add(lidOpenSlider.el); - lidGroup.add(lidLowerSlider.el); - lidGroup.add(lidWidthSlider.el); - lidGroup.add(lidTiltSlider.el); - lidGroup.add(controlRow(null, lashesToggle.el, { wide: true })); - 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); @@ -741,8 +780,8 @@ export function buildPanel(app, root) { }], })); - faceSection.add(hint('「まぶた」は両目で共通です。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、' - + '目の形・大きさ・白目・光彩・目線が両目でそろいます。' + faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、' + + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。' + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。')); const eyeColors = { @@ -2774,12 +2813,6 @@ export function buildPanel(app, root) { w.eyeX.set(eye.eyeX ?? 0); } - lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); - lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve'); - lidTiltSlider.set(state.face.eyes.lidTilt ?? 0); - lidOpenSlider.set(state.face.eyes.left.open ?? 1); - lidLowerSlider.set(state.face.eyes.left.lowerLid ?? state.face.eyes.lowerLid ?? 0); - lashesToggle.set(state.face.eyes.lashes === true); 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 ccd6237..5f1d412 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: { 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 }, + 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, lidShape: null, lidWidth: null, lidTilt: null, lashes: 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, lidShape: null, lidWidth: null, lidTilt: null, lashes: null }, irisScale: 1, lookMax: 1.2, highlight: true, diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css index 61c53db..a147b1f 100644 --- a/public/bluebey-studio/src/style.css +++ b/public/bluebey-studio/src/style.css @@ -399,7 +399,11 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); } font-size: 11.5px; font-weight: 700; color: #6b5c92; + display: flex; + align-items: center; + gap: 6px; } +.eye-group-head .grp-icon { color: #8a7ab8; } .eye-group-body { display: grid; gap: 8px; } .select { flex: 1; diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js index b4c7d24..91e852b 100644 --- a/public/bluebey-studio/src/ui.js +++ b/public/bluebey-studio/src/ui.js @@ -74,9 +74,6 @@ function tabIcon(d) { svg.setAttribute('stroke-width', '1.8'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); - svg.setAttribute('aria-hidden', 'true'); - // Sit the icon on the label's text baseline; the span and the text node keep - // the two from touching. svg.style.verticalAlign = 'baseline'; svg.style.flex = '0 0 auto'; const path = document.createElementNS(SVG_NS, 'path'); @@ -85,6 +82,23 @@ function tabIcon(d) { return svg; } +/** A small stroke icon built from raw SVG markup (see the icons in panel.js). */ +export function svgIcon(inner, { size = 16, viewBox = '0 0 24 24', className = 'grp-icon' } = {}) { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', viewBox); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.8'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('class', className); + svg.innerHTML = inner; + return svg; +} + function secIcon(d) { const svg = tabIcon(d); svg.setAttribute('class', 'sec-icon'); |
