aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--public/bluebey-studio/src/faceArt.js11
-rw-r--r--public/bluebey-studio/src/main.js5
-rw-r--r--public/bluebey-studio/src/panel.js149
-rw-r--r--public/bluebey-studio/src/presets.js4
-rw-r--r--public/bluebey-studio/src/style.css4
-rw-r--r--public/bluebey-studio/src/ui.js20
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');