aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src')
-rw-r--r--public/bluebey-studio/src/faceArt.js3
-rw-r--r--public/bluebey-studio/src/panel.js306
-rw-r--r--public/bluebey-studio/src/presets.js4
-rw-r--r--public/bluebey-studio/src/style.css13
4 files changed, 162 insertions, 164 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 01e5c31..71e7487 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -1314,7 +1314,6 @@ export function drawEyes(ctx, p) {
const lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke;
const irisScale = style.irisScale ?? 1;
const lookMax = style.lookMax ?? 1;
- const highlightOn = style.highlight !== false;
const heartScale = 2;
const heartColor = style.heartColor ?? '#e0344f';
const irisRadius = EYE_LAYOUT.irisRadius * irisScale;
@@ -1424,6 +1423,8 @@ export function drawEyes(ctx, p) {
} else {
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;
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index b2c3519..6cf8c40 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -440,7 +440,6 @@ export function buildPanel(app, root) {
// --- eyes
faceSection = faceTabSections.eye;
- const eyeWidgets = {};
/** Tear sliders, which are added to the こまかい設定 box once it exists. */
const tearWidgets = [];
// The link switch sits directly above the *left* eye's open slider, because that
@@ -459,6 +458,7 @@ 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.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;
state.face.eyes.right.lookY = state.face.eyes.left.lookY;
// The tears mirror as a pair: the size and the height match, while the
@@ -499,22 +499,36 @@ export function buildPanel(app, root) {
// The usual: an open eye shows its white; a shut eye or a heart does not.
return eyeShapeOf(eye) === 'open';
};
+ const eyeHighlightOf = (eye) => (eye.highlight != null
+ ? eye.highlight === true
+ : state.face.eyes.highlight !== false);
+
+ // 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 body = h('div', { class: 'eye-group-body' });
+ const el = h('div', { class: 'eye-group' }, h('div', { class: 'eye-group-head', text: title }), body);
+ return {
+ el,
+ add: (child) => (body.append(child?.el ?? child), child),
+ setVisible: (visible) => { el.style.display = visible ? '' : 'none'; },
+ };
+ };
+
+ const makeEyeControls = (mode) => {
+ const keys = mode === 'both' ? ['left', 'right'] : [mode];
+ 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' ? '左の目' : '右の目'));
- for (const key of ['left', 'right']) {
- const label = key === 'left' ? '左の目' : '右の目';
const open = slider({
- label: `${label}の開き`,
- min: 0, max: 1, step: 0.01, value: 1,
+ label: '目の開き', min: 0, max: 1, step: 0.01, value: 1,
format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`),
- onInput: (value) => {
- state.face.eyes[key].open = value;
- if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].open = value;
- app.actions.refresh('face');
- sync();
- },
+ onInput: (v) => { write((e) => { e.open = v; }); touched(); },
});
const shape = segmented({
- label: `${label}の形`,
+ label: '目の形',
options: [
{ value: 'open', label: 'ふつう' },
{ value: 'line', label: '線' },
@@ -523,15 +537,10 @@ export function buildPanel(app, root) {
{ value: 'heart', label: 'ハート' },
],
value: 'open',
- onChange: (value) => {
- applyEyeShape(state.face.eyes[key], value);
- if (state.face.eyes.linked) applyEyeShape(state.face.eyes[key === 'left' ? 'right' : 'left'], value);
- app.actions.refresh('face');
- sync();
- },
+ onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); },
});
const closedLines = segmented({
- label: `${label}の線の数`,
+ label: '線の数',
options: [
{ value: '1', label: '1本' },
{ value: '2', label: '2本' },
@@ -539,45 +548,78 @@ export function buildPanel(app, root) {
],
value: '1',
hint: '2本以上は端点がつながった形になります',
- onChange: (value) => {
- state.face.eyes[key].closedLines = Number(value);
- applyEyeShape(state.face.eyes[key], 'line');
- if (state.face.eyes.linked) {
- const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
- other.closedLines = Number(value);
- applyEyeShape(other, 'line');
- }
- app.actions.refresh('face');
- sync();
- },
+ onChange: (v) => { write((e) => { e.closedLines = Number(v); applyEyeShape(e, 'line'); }); touched(); },
});
- // The direction of a "3" eye. It used to be tied to the nose side, which made
- // the pair a mirror image; now each eye says which way its 3 faces.
const threeFlip = segmented({
- label: `${label}の3の向き`,
+ label: '3の向き',
options: [
{ value: 'normal', label: '3' },
{ value: 'flip', label: '3(逆)' },
],
value: 'normal',
- hint: '「3の目」のときの向きです。左右で別々に選べます',
- onChange: (value) => {
- state.face.eyes[key].threeFlip = value === 'flip';
- applyEyeShape(state.face.eyes[key], 'three');
- app.actions.refresh('face');
- sync();
- },
+ onChange: (v) => { write((e) => { e.threeFlip = v === 'flip'; applyEyeShape(e, 'three'); }); touched(); },
+ });
+ const white = check({
+ label: '白目を出す',
+ value: true,
+ onChange: (v) => { write((e) => { e.white = v; }); touched(); },
+ });
+ const highlight = check({
+ label: '光彩(瞳の白い丸)を出す',
+ value: true,
+ onChange: (v) => { write((e) => { e.highlight = v; }); touched(); },
});
- // Where this eye sits: per eye, so a lopsided face (one eye slid across) is
- // possible.
const eyeX = slider({
- label: `${label}のよこ位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
- onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); },
+ label: '横位置', min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { write((e) => { e.eyeX = v; }); touched(); },
+ });
+ const pad = xyPad({
+ value: { x: 0, y: 0 },
+ center: 'eye',
+ onChange: ({ x, y }) => { write((e) => { e.lookX = x; e.lookY = y; }); touched(); },
});
- // Tears are per eye, and so are where they hang and which way they lean.
- // When the eyes are linked, a tear edit mirrors onto the other eye: the size
- // and the height are copied, but the sideways position and the tilt flip
- // sign. Two drops leaning the same way read as a tilted face, not as crying.
+
+ group.add(open);
+ group.add(shape);
+ group.add(closedLines);
+ group.add(threeFlip);
+ group.add(controlRow(null, white.el, { wide: true }));
+ group.add(controlRow(null, highlight.el, { wide: true }));
+ group.add(eyeX);
+ group.add(controlRow('目線', h('div', { class: 'pad-wrap' }, pad.el,
+ h('p', { class: 'hint', text: 'ドラッグで目線。ダブルクリックで正面に戻ります。' }))));
+
+ const syncOne = () => {
+ const e = state.face.eyes[primary];
+ const shapeValue = eyeShapeOf(e);
+ open.set(e.open);
+ shape.set(shapeValue);
+ closedLines.set(String(e.closedLines ?? 1));
+ closedLines.el.style.display = shapeValue === 'line' ? '' : 'none';
+ threeFlip.set(e.threeFlip ? 'flip' : 'normal');
+ threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none';
+ white.set(eyeWhiteOf(e));
+ highlight.set(eyeHighlightOf(e));
+ eyeX.set(e.eyeX ?? 0);
+ pad.set({ x: e.lookX ?? 0, y: e.lookY ?? 0 });
+ };
+ return { group, sync: syncOne };
+ };
+
+ const bothControls = makeEyeControls('both');
+ const leftControls = makeEyeControls('left');
+ const rightControls = makeEyeControls('right');
+
+ faceSection.add(controlRow(null, linkedToggle.el, { wide: true }));
+ faceSection.add(bothControls.group);
+ faceSection.add(leftControls.group);
+ faceSection.add(rightControls.group);
+
+ // 下まぶた・涙・目線スライダー (per eye) live in こまかい設定; the tears mirror
+ // onto the other eye while the two are linked.
+ const eyeAdvancedWidgets = {};
+ for (const key of ['left', 'right']) {
+ const label = key === 'left' ? '左の目' : '右の目';
const otherKey = key === 'left' ? 'right' : 'left';
const setTear = (patch) => {
Object.assign(state.face.eyes[key], patch);
@@ -606,7 +648,7 @@ export function buildPanel(app, root) {
label: `${label}の涙の傾き`, min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
onInput: (value) => setTear({ tearTilt: value }),
});
- const lowerLidEach = slider({
+ const lowerLid = slider({
label: `${label}の下まぶた`, min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
onInput: (value) => { state.face.eyes[key].lowerLid = value; app.actions.refresh('face'); },
});
@@ -618,29 +660,8 @@ export function buildPanel(app, root) {
label: `${label}の目線(たて)`, min: -1, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
onInput: (value) => { state.face.eyes[key].lookY = value; app.actions.refresh('face'); },
});
- // 白目: whether this eye shows the white of the eyeball. Stored tri-state (a
- // null means "the usual" - open eyes show it, shut ones do not), shown as a
- // plain on/off.
- const white = check({
- label: `${label}の白目`,
- value: true,
- onChange: (value) => {
- state.face.eyes[key].white = value;
- if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].white = value;
- app.actions.refresh('face');
- sync();
- },
- });
- eyeWidgets[key] = { open, shape, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt, lowerLid: lowerLidEach, lookX, lookY, white };
- if (key === 'left') faceSection.add(controlRow(null, linkedToggle.el, { wide: true }));
- faceSection.add(open.el);
- faceSection.add(shape.el);
- faceSection.add(closedLines.el);
- faceSection.add(threeFlip.el);
- faceSection.add(controlRow(null, white.el, { wide: true }));
- faceSection.add(eyeX.el);
- // 目線 and 下まぶた (per eye) and the tears go into the こまかい設定 box later.
- tearWidgets.push(lowerLidEach.el, lookX.el, lookY.el, tear.el, tearY.el, tearX.el, tearTilt.el);
+ eyeAdvancedWidgets[key] = { tear, tearY, tearX, tearTilt, lowerLid, lookX, lookY };
+ tearWidgets.push(lowerLid.el, lookX.el, lookY.el, tear.el, tearY.el, tearX.el, tearTilt.el);
}
// One button to put every eye setting back to the artwork's defaults. The
@@ -659,75 +680,41 @@ export function buildPanel(app, root) {
}],
}));
- const linkedNote = hint('「左右を連動させる」を入れると、開き・形・線の数・白目・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
- faceSection.add(linkedNote);
+ faceSection.add(hint('「左右を連動させる」を入れると「両目」の設定が1つだけ出て、開き・形・白目・光彩・目線が両目でそろいます。'
+ + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。'
+ + '涙のよこ位置と傾きは左右で反転し、線対称になります。'));
- // 光彩 (the white glint in the iris). Kept here beside the eye shape, because it
- // is part of the eye's look rather than an advanced extra.
- const highlightToggle = check({
- label: '光彩(瞳の白い丸)',
- value: true,
- onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); },
- });
- faceSection.add(controlRow(null, highlightToggle.el, { wide: true }));
-
- // Drag the gaze of one eye at a time. `linked` copies the movement onto the
- // other eye, so a linked pair still moves together.
- let lookTarget = 'left';
- const lookEyeSegment = segmented({
- label: '動かす目',
- options: [
- { value: 'left', label: '左の目' },
- { value: 'right', label: '右の目' },
- ],
- value: 'left',
- onChange: (value) => {
- lookTarget = value;
- const eye = state.face.eyes[value];
- lookPad.set({ x: eye.lookX ?? 0, y: eye.lookY ?? 0 });
- },
- });
- faceSection.add(lookEyeSegment.el);
-
- const lookPad = xyPad({
- value: { x: 0, y: 0 },
- center: 'eye',
- onChange: ({ x, y }) => {
- const other = lookTarget === 'left' ? 'right' : 'left';
- state.face.eyes[lookTarget].lookX = x;
- state.face.eyes[lookTarget].lookY = y;
- if (state.face.eyes.linked) {
- state.face.eyes[other].lookX = x;
- state.face.eyes[other].lookY = y;
- }
- app.actions.refresh('face');
- sync();
- },
- });
- faceSection.add(controlRow('目線', h('div', { class: 'pad-wrap' },
- lookPad.el,
- h('p', { class: 'hint', text: 'ドラッグで「動かす目」を動かします。ダブルクリックで正面に戻ります。' }))));
-
- // 目を細める: a one-tap マンガの細目, kept out of the こまかい設定 box so it is
- // easy to find.
- faceSection.add(buttons({
- items: [{
- id: 'squint',
- label: '目を細める',
- onClick: () => {
- // The upper lid comes partway down and the lower lid rises to meet it.
- // `open` is per-eye (kept in step here); `lowerLid` is shared.
- state.face.eyes.left.open = 0.5;
- state.face.eyes.right.open = 0.5;
- state.face.eyes.lowerLid = 0.5;
- app.actions.refresh('face');
- sync();
+ // 目を細める (and its undo) grouped with the lid shape and tilt that shape the
+ // squint, so the whole narrowed-eye look sits together.
+ const squintGroup = eyeGroup('目を細める');
+ squintGroup.add(buttons({
+ items: [
+ {
+ id: 'squint',
+ label: '目を細める',
+ onClick: () => {
+ // The upper lid comes partway down and the lower lid rises to meet it.
+ // `open` is per eye (kept in step here); `lowerLid` is shared.
+ state.face.eyes.left.open = 0.5;
+ state.face.eyes.right.open = 0.5;
+ state.face.eyes.lowerLid = 0.5;
+ app.actions.refresh('face');
+ sync();
+ },
},
- }],
+ {
+ id: 'unsquint',
+ label: '目を細めない',
+ onClick: () => {
+ state.face.eyes.left.open = 1;
+ state.face.eyes.right.open = 1;
+ state.face.eyes.lowerLid = 0;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
}));
-
- // まぶたの形 and 目の傾き sit right next to 目を細める, so the narrowed eye can be
- // tuned without hunting inside こまかい設定.
const lidShapeSegment = segmented({
label: 'まぶたの形',
options: [
@@ -737,14 +724,15 @@ export function buildPanel(app, root) {
value: 'curve',
onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); },
});
- faceSection.add(lidShapeSegment.el);
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'); },
});
- faceSection.add(lidTiltSlider.el);
- faceSection.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。'
+ squintGroup.add(lidShapeSegment.el);
+ squintGroup.add(lidTiltSlider.el);
+ squintGroup.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。'
+ '「まぶたの傾き」で、細めた目を左右反転で傾けられます。'));
+ faceSection.add(squintGroup);
const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・色・まぶたの太さ・左右べつの設定・手描き)');
const irisScaleSlider = slider({
@@ -2749,30 +2737,26 @@ export function buildPanel(app, root) {
syncers.push(...boneSyncers);
syncers.push(() => {
+ const linked = state.face.eyes.linked !== false;
+ bothControls.group.setVisible(linked);
+ leftControls.group.setVisible(!linked);
+ rightControls.group.setVisible(!linked);
+ bothControls.sync();
+ leftControls.sync();
+ rightControls.sync();
+
for (const key of ['left', 'right']) {
const eye = state.face.eyes[key];
- const shapeValue = eyeShapeOf(eye);
- eyeWidgets[key].open.set(eye.open);
- eyeWidgets[key].shape.set(shapeValue);
- eyeWidgets[key].closedLines.set(String(eye.closedLines ?? 1));
- eyeWidgets[key].closedLines.el.style.display = shapeValue === 'line' ? '' : 'none';
- eyeWidgets[key].threeFlip.set(eye.threeFlip ? 'flip' : 'normal');
- // The direction only means anything for the "3" eye; the line count only
- // for the plain line.
- eyeWidgets[key].threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none';
- eyeWidgets[key].white.set(eyeWhiteOf(eye));
- eyeWidgets[key].tear.set(eye.tear ?? 0);
- eyeWidgets[key].tearY.set(eye.tearY ?? 0);
- eyeWidgets[key].tearX.set(eye.tearX ?? 0);
- eyeWidgets[key].tearTilt.set(eye.tearTilt ?? 0);
- eyeWidgets[key].eyeX.set(eye.eyeX ?? 0);
- eyeWidgets[key].lookX.set(eye.lookX ?? 0);
- eyeWidgets[key].lookY.set(eye.lookY ?? 0);
- eyeWidgets[key].lowerLid.set(eye.lowerLid ?? state.face.eyes.lowerLid ?? 0);
+ const w = eyeAdvancedWidgets[key];
+ w.tear.set(eye.tear ?? 0);
+ w.tearY.set(eye.tearY ?? 0);
+ w.tearX.set(eye.tearX ?? 0);
+ w.tearTilt.set(eye.tearTilt ?? 0);
+ w.lowerLid.set(eye.lowerLid ?? state.face.eyes.lowerLid ?? 0);
+ w.lookX.set(eye.lookX ?? 0);
+ w.lookY.set(eye.lookY ?? 0);
}
- lookPad.set({ x: state.face.eyes[lookTarget].lookX ?? 0, y: state.face.eyes[lookTarget].lookY ?? 0 });
- highlightToggle.set(state.face.eyes.highlight);
irisScaleSlider.set(state.face.eyes.irisScale);
heartColorField.set(state.face.eyes.heartColor ?? '#e0344f');
lidWidthSlider.set(state.face.eyes.lidWidth ?? 14);
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 5ba46af..35d1276 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, tearY: 0, tearX: 0, tearTilt: 0, white: null },
- right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0, white: null },
+ left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null },
+ right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: 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 ffcbac3..efd0a18 100644
--- a/public/bluebey-studio/src/style.css
+++ b/public/bluebey-studio/src/style.css
@@ -346,6 +346,19 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
text-transform: uppercase;
margin-top: 3px;
}
+.eye-group {
+ border-top: 1px solid var(--border);
+ margin-top: 4px;
+ padding-top: 8px;
+ display: grid;
+ gap: 8px;
+}
+.eye-group-head {
+ font-size: 11.5px;
+ font-weight: 700;
+ color: #6b5c92;
+}
+.eye-group-body { display: grid; gap: 8px; }
.select {
flex: 1;
min-width: 0;