aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--public/bluebey-studio/src/exporter.js51
-rw-r--r--public/bluebey-studio/src/face.js2
-rw-r--r--public/bluebey-studio/src/faceArt.js14
-rw-r--r--public/bluebey-studio/src/main.js67
-rw-r--r--public/bluebey-studio/src/panel.js236
-rw-r--r--public/bluebey-studio/src/presets.js17
-rw-r--r--public/bluebey-studio/src/style.css2
7 files changed, 277 insertions, 112 deletions
diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js
index 147af1c..b34b6f6 100644
--- a/public/bluebey-studio/src/exporter.js
+++ b/public/bluebey-studio/src/exporter.js
@@ -1,7 +1,7 @@
import { traceAlphaContours, contoursToPathData } from './trace.js';
import { roughenContours } from './handDrawn.js';
import { captionToSvg } from './caption.js';
-import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js';
+import { EYE_LAYOUT, MOUTH_LAYOUT, HAIR_WRAP_LAYOUT } from './faceArt.js';
/**
* Export helpers.
@@ -275,7 +275,54 @@ export function buildFaceMap(face, kind) {
return canvas;
}
-/** One guide segment, kept out of `buildFaceMap` so the placements stay readable. */
+/**
+ * The 髪の下地 (a base to draw hair on).
+ *
+ * The hair plate is a cylinder unrolled: `x` is the angle round the head, with
+ * the face at the middle of the artwork window and the seam at the back at the
+ * two edges, and `y` is the height, top of the head first. The current hair is
+ * copied in as a faint reference, and guides mark the window, the front centre
+ * and the height the default まえがみ hairline reaches.
+ *
+ * @param {import('./face.js').Face} face
+ * @returns {HTMLCanvasElement|null}
+ */
+export function buildHairMap(face) {
+ const layout = face.hairLayout;
+ const plate = face.hairCanvas;
+ if (!layout || !plate) return null;
+ // The plate's UVs were rewritten to fill 0..1, so the whole canvas is the
+ // texture (unlike the eye/mouth maps, which keep a window inside a wider
+ // canvas). Every guide is therefore measured straight against the canvas.
+ const canvas = document.createElement('canvas');
+ canvas.width = plate.width;
+ canvas.height = plate.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(plate, 0, 0);
+
+ // White paper under the copy, so a redraw starts from an opaque base.
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.globalCompositeOperation = 'source-over';
+
+ ctx.strokeStyle = FACE_MAP_GUIDE;
+ ctx.fillStyle = FACE_MAP_GUIDE;
+ ctx.lineWidth = 3;
+
+ // The front of the head sits at the middle of the artwork window, which is
+ // where `drawHeadCoverWrap` puts the face, so this vertical line is the
+ // nose-to-backbone plane. The canvas's own left/right edges are the seam at
+ // the back of the head.
+ const fx = (layout.offsetX + layout.window.width * 0.5) * (canvas.width / layout.width);
+ guideLine(ctx, fx, 0, fx, canvas.height);
+ // The default hairline (大きさ 1) runs across the whole wrap at this height.
+ guideLine(ctx, 0, HAIR_WRAP_LAYOUT.base * canvas.height, canvas.width, HAIR_WRAP_LAYOUT.base * canvas.height);
+
+ return canvas;
+}
+
+/** One guide segment, kept out of the map builders so the placements stay readable. */
function guideLine(ctx, x0, y0, x1, y1) {
ctx.beginPath();
ctx.moveTo(x0, y0);
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index 78b2b8d..b468cd5 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -237,7 +237,6 @@ export class Face {
lowerLid: p.eyes.lowerLid,
lidShape: p.eyes.lidShape,
lidTilt: p.eyes.lidTilt,
- hideWhite: p.eyes.hideWhite === true,
brow: p.eyes.brow,
// ほっぺ and 頭の模様 are shared by the whole face, so they travel with
// the other shared eye fields (see `drawEyes` in src/faceArt.js).
@@ -256,7 +255,6 @@ export class Face {
glasses: p.eyes.glasses,
heartScale: p.eyes.heartScale,
heartColor: p.eyes.heartColor,
- heartHideWhite: p.eyes.heartHideWhite === true,
tearColor: p.eyes.tearColor,
// Where the artwork's real edges are, so a brow lifted too far or a
// tear dropped too low can stop inside the artwork (see `canvasLayout`).
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 1f7c4ee..01e5c31 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -1379,6 +1379,9 @@ export function drawEyes(ctx, p) {
};
if (open <= 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.
+ if (!line && spec.white === true) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
drawShutEye(ctx, eye, spec, lineStyle);
// The glasses sit over the shut-eye artwork too, but under the tear below.
drawGlasses(ctx, eye, line, style, limits);
@@ -1402,11 +1405,12 @@ export function drawEyes(ctx, p) {
dy = (dy / dist) * eyeTravel;
}
- // The eyeball's white can be hidden: for a heart it stops the white showing
- // round the lobes, and `hideWhite` does the same for any style (e.g. a line
- // squint) when the user asks for it.
- const hideWhite = style.hideWhite === true || (isHeart && style.heartHideWhite);
- if (!line && !hideWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
+ // The white of the eye. `spec.white` is per eye and tri-state: `null` keeps
+ // the usual behaviour (an open eye shows its white, a heart does not), while
+ // true/false force it on or off - so an open eye can lose its white, and a
+ // shut eye or a heart can gain it.
+ const showWhite = spec.white == null ? !isHeart : spec.white === true;
+ if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
const irisX = eye.cx + dx;
const irisY = eye.cy + dy;
if (isHeart) {
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 01ee85a..d4866c7 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -381,30 +381,50 @@ async function init() {
let hatKindCurrent = null;
let hatObject = null;
const hatMeshes = [];
+ /** Last transform applied, so the render loop only wakes when it changes. */
+ const hatXform = { height: null, tiltX: null, tiltZ: null };
/** Set once the outline pass exists, so a hat change can refresh the exclude set. */
let refreshOutlineExclusion = null;
function applyHat(kind) {
- if (kind === hatKindCurrent) return;
- hatKindCurrent = kind;
if (!hatMount) return;
+ if (kind !== hatKindCurrent) {
+ hatKindCurrent = kind;
+ if (hatObject) {
+ hatMount.remove(hatObject);
+ hatObject.traverse((o) => {
+ o.geometry?.dispose();
+ if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
+ else o.material?.dispose();
+ });
+ hatObject = null;
+ }
+ hatMeshes.length = 0;
+ const built = buildHat(kind);
+ if (built) {
+ hatMount.add(built);
+ hatObject = built;
+ built.traverse((o) => { if (o.isMesh) hatMeshes.push(o); });
+ }
+ refreshOutlineExclusion?.();
+ }
+
+ // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the
+ // character's front and +x its right: a plain translation and rotation read
+ // the way the sliders say.
+ const hat = state.face.hat ?? {};
+ const height = hat.height ?? 0;
+ const tiltX = hat.tiltX ?? 0;
+ const tiltZ = hat.tiltZ ?? 0;
if (hatObject) {
- hatMount.remove(hatObject);
- hatObject.traverse((o) => {
- o.geometry?.dispose();
- if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
- else o.material?.dispose();
- });
- hatObject = null;
+ hatObject.position.y = height;
+ hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180);
}
- hatMeshes.length = 0;
- const built = buildHat(kind);
- if (built) {
- hatMount.add(built);
- hatObject = built;
- built.traverse((o) => { if (o.isMesh) hatMeshes.push(o); });
+ if (height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) {
+ hatXform.height = height;
+ hatXform.tiltX = tiltX;
+ hatXform.tiltZ = tiltZ;
+ app.needsRender = true;
}
- refreshOutlineExclusion?.();
- app.needsRender = true;
}
applyHat(state.face.hat?.kind ?? 'none');
@@ -1932,14 +1952,23 @@ async function init() {
// `face.flush(now, 26)`), so force any pending one through first or the map
// would show the expression before last.
face.flush(performance.now(), 0);
- return exporter.buildFaceMap(face, kind);
+ return kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind);
}
async function saveFaceMap(kind) {
const canvas = faceMapCanvas(kind);
+ if (!canvas) {
+ toast('このモデルには髪のプレートがありません');
+ return;
+ }
const blob = await exporter.canvasToBlob(canvas);
- const label = kind === 'eyes' ? '目' : '口';
+ const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪';
exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`);
+ if (kind === 'hair') {
+ toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ + 'この画像に前髪を描いて渡してください');
+ return;
+ }
toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`);
}
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index ed7f766..b2c3519 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -148,6 +148,17 @@ export function buildPanel(app, root) {
const sync = () => { for (const fn of syncers) fn(); };
const syncBones = () => { for (const fn of boneSyncers) fn(); };
+ /**
+ * Grey out (and disable) a control whose setting has no effect in the current
+ * mode - e.g. the brow's angle while the brow is a loaded picture.
+ */
+ const setRowEnabled = (widget, on) => {
+ const row = widget.el.closest?.('.row') ?? widget.el;
+ const input = row.querySelector?.('input, select, button');
+ if (input) input.disabled = !on;
+ row.classList?.toggle('is-off', !on);
+ };
+
/** Patch one scope of the state without re-reading the whole UI. */
/** Replace the state and re-read every widget (used by presets and loads). */
const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true });
@@ -446,6 +457,10 @@ export function buildPanel(app, root) {
state.face.eyes.right.open = state.face.eyes.left.open;
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.white = state.face.eyes.left.white ?? 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
// sideways position and the tilt flip sign.
state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0;
@@ -457,6 +472,34 @@ export function buildPanel(app, root) {
sync();
},
});
+ // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into
+ // one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and
+ // writes them together so the menu always shows the current look.
+ const eyeShapeOf = (eye) => {
+ // A shut eye wins over a heart: dragging the 開き slider down leaves a shut
+ // line (or 3/わらう), even if the heart pupil is still selected underneath.
+ if ((eye.open ?? 1) <= 0.02) {
+ if (eye.closed === 'three') return 'three';
+ if (eye.closed === 'arch') return 'arch';
+ return 'line';
+ }
+ return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
+ };
+ const applyEyeShape = (eye, value) => {
+ eye.irisShape = value === 'heart' ? 'heart' : 'circle';
+ if (value === 'open' || value === 'heart') {
+ eye.open = 1;
+ return;
+ }
+ eye.open = 0;
+ eye.closed = value;
+ };
+ const eyeWhiteOf = (eye) => {
+ if (eye.white != null) return eye.white === true;
+ // The usual: an open eye shows its white; a shut eye or a heart does not.
+ return eyeShapeOf(eye) === 'open';
+ };
+
for (const key of ['left', 'right']) {
const label = key === 'left' ? '左の目' : '右の目';
const open = slider({
@@ -470,23 +513,19 @@ export function buildPanel(app, root) {
sync();
},
});
- const closed = segmented({
- label: `${label}の閉じ方`,
+ const shape = segmented({
+ label: `${label}の形`,
options: [
+ { value: 'open', label: 'ふつう' },
{ value: 'line', label: '線' },
{ value: 'three', label: '3の目' },
{ value: 'arch', label: 'わらう' },
+ { value: 'heart', label: 'ハート' },
],
- value: 'line',
+ value: 'open',
onChange: (value) => {
- state.face.eyes[key].closed = value;
- // 線 and わらう are the shut-eye artwork, so shut the eye for the user.
- state.face.eyes[key].open = 0;
- if (state.face.eyes.linked) {
- const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
- other.open = 0;
- other.closed = 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();
},
@@ -502,13 +541,11 @@ export function buildPanel(app, root) {
hint: '2本以上は端点がつながった形になります',
onChange: (value) => {
state.face.eyes[key].closedLines = Number(value);
- state.face.eyes[key].closed = 'line';
- state.face.eyes[key].open = 0;
+ applyEyeShape(state.face.eyes[key], 'line');
if (state.face.eyes.linked) {
const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
- other.open = 0;
- other.closed = 'line';
other.closedLines = Number(value);
+ applyEyeShape(other, 'line');
}
app.actions.refresh('face');
sync();
@@ -526,8 +563,7 @@ export function buildPanel(app, root) {
hint: '「3の目」のときの向きです。左右で別々に選べます',
onChange: (value) => {
state.face.eyes[key].threeFlip = value === 'flip';
- state.face.eyes[key].closed = 'three';
- state.face.eyes[key].open = 0;
+ applyEyeShape(state.face.eyes[key], 'three');
app.actions.refresh('face');
sync();
},
@@ -582,12 +618,26 @@ 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'); },
});
- eyeWidgets[key] = { open, closed, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt, lowerLid: lowerLidEach, lookX, lookY };
+ // 白目: 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(closed.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);
@@ -609,39 +659,54 @@ export function buildPanel(app, root) {
}],
}));
- const irisShapeSegment = segmented({
- label: '瞳の形',
+ const linkedNote = hint('「左右を連動させる」を入れると、開き・形・線の数・白目・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
+ faceSection.add(linkedNote);
+
+ // 光彩 (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: 'circle', label: '丸' },
- { value: 'heart', label: 'ハート' },
+ { value: 'left', label: '左の目' },
+ { value: 'right', label: '右の目' },
],
- value: 'circle',
+ value: 'left',
onChange: (value) => {
- state.face.eyes.left.irisShape = value;
- state.face.eyes.right.irisShape = value;
- app.actions.refresh('face');
+ lookTarget = value;
+ const eye = state.face.eyes[value];
+ lookPad.set({ x: eye.lookX ?? 0, y: eye.lookY ?? 0 });
},
});
- faceSection.add(irisShapeSegment.el);
-
- const linkedNote = hint('「左右を連動させる」を入れると、開き・閉じ方・線の数・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
- faceSection.add(linkedNote);
+ faceSection.add(lookEyeSegment.el);
const lookPad = xyPad({
value: { x: 0, y: 0 },
center: 'eye',
onChange: ({ x, y }) => {
- state.face.eyes.left.lookX = x;
- state.face.eyes.left.lookY = y;
- state.face.eyes.right.lookX = x;
- state.face.eyes.right.lookY = 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: 'ドラッグで両目が動きます。ダブルクリックで正面に戻ります。' }))));
+ h('p', { class: 'hint', text: 'ドラッグで「動かす目」を動かします。ダブルクリックで正面に戻ります。' }))));
// 目を細める: a one-tap マンガの細目, kept out of the こまかい設定 box so it is
// easy to find.
@@ -678,15 +743,8 @@ export function buildPanel(app, root) {
onInput: (value) => { state.face.eyes.lidTilt = value; app.actions.refresh('face'); },
});
faceSection.add(lidTiltSlider.el);
- const hideWhiteToggle = check({
- label: '白目を消す',
- value: false,
- onChange: (value) => { state.face.eyes.hideWhite = value; app.actions.refresh('face'); },
- });
- faceSection.add(controlRow(null, hideWhiteToggle.el, { wide: true }));
faceSection.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。'
- + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。'
- + '「白目を消す」は、細めた目やハートのときに白目を出しません。'));
+ + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。'));
const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・色・まぶたの太さ・左右べつの設定・手描き)');
const irisScaleSlider = slider({
@@ -705,16 +763,9 @@ export function buildPanel(app, root) {
eyeAdvanced.add(irisScaleSlider.el);
eyeAdvanced.add(heartColorField.el);
eyeAdvanced.add(lidWidthSlider.el);
- eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「目を細める」「白目を消す」は、この上の「目」にあります。'
+ eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「目を細める」「白目」「光彩」は、この上の「目」にあります。'
+ 'ハートの大きさは「瞳の大きさ」で変わります。'));
- const highlightToggle = check({
- label: '瞳のハイライト',
- value: true,
- onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); },
- });
- eyeAdvanced.add(controlRow(null, highlightToggle.el, { wide: true }));
-
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'); } }),
@@ -846,7 +897,7 @@ export function buildPanel(app, root) {
items: [
{
id: 'golgo',
- label: 'ゴル風',
+ label: 'ゴル風(画像)',
onClick: () => {
// ゴルゴ13: a very thick, almost straight black wedge, wide at the outer
// end and tapering to a point at the nose, angled steeply down towards
@@ -869,7 +920,7 @@ export function buildPanel(app, root) {
},
{
id: 'ryotsu',
- label: '両風',
+ label: '両風(線の眉)',
onClick: () => {
// 両津勘吉: thick げじげじ brows that are joined in the middle, sitting
// low over the eyes. `spacing` is what closes the gap - at this length
@@ -895,8 +946,9 @@ export function buildPanel(app, root) {
],
}));
- faceSection.add(hint('「ゴル風」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。'
- + '「両風」など線で描く眉に切り替えると、この画像は使われません。'));
+ faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。'
+ + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。'
+ + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。'));
// --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
faceSection = faceTabSections.glasses;
@@ -1627,6 +1679,29 @@ export function buildPanel(app, root) {
hatSection.add(hatSegment.el);
syncers.push(() => hatSegment.set(state.face.hat?.kind ?? 'none'));
+ const hatBox = details(hatSection.body, '位置と傾き');
+ const hatSliders = {
+ height: slider({
+ label: '高さ', min: -0.6, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); },
+ }),
+ tiltX: slider({
+ label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); },
+ }),
+ tiltZ: slider({
+ label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); },
+ }),
+ };
+ for (const widget of Object.values(hatSliders)) hatBox.add(widget.el);
+ hatBox.add(hint('「高さ」で帽子を浮かせたり深くかぶせたり、「前後の傾き」「左右の傾き」で斜めにかぶせられます。'));
+ syncers.push(() => {
+ hatSliders.height.set(state.face.hat?.height ?? 0);
+ hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0);
+ hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0);
+ });
+
/* ------------------------------------------------------------- body colour */
const colourSection = section(tabView, '体の色');
@@ -2605,12 +2680,16 @@ export function buildPanel(app, root) {
items: [
{ id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') },
{ id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') },
+ { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') },
],
}));
faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。'
+ 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や'
+ '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。'
- + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'));
+ + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'
+ + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、'
+ + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、'
+ + '青いよこ線が既定の生え際です。'));
/* --------------------------------------------------------------- settings */
@@ -2671,31 +2750,34 @@ export function buildPanel(app, root) {
syncers.push(() => {
for (const key of ['left', 'right']) {
- eyeWidgets[key].open.set(state.face.eyes[key].open);
- eyeWidgets[key].closed.set(state.face.eyes[key].closed);
- eyeWidgets[key].closedLines.set(String(state.face.eyes[key].closedLines ?? 1));
- eyeWidgets[key].threeFlip.set(state.face.eyes[key].threeFlip ? 'flip' : 'normal');
- // The direction only means anything for the "3" eye.
- eyeWidgets[key].threeFlip.el.style.display = state.face.eyes[key].closed === 'three' ? '' : 'none';
- eyeWidgets[key].tear.set(state.face.eyes[key].tear ?? 0);
- eyeWidgets[key].tearY.set(state.face.eyes[key].tearY ?? 0);
- eyeWidgets[key].tearX.set(state.face.eyes[key].tearX ?? 0);
- eyeWidgets[key].tearTilt.set(state.face.eyes[key].tearTilt ?? 0);
- eyeWidgets[key].eyeX.set(state.face.eyes[key].eyeX ?? 0);
- eyeWidgets[key].lookX.set(state.face.eyes[key].lookX ?? 0);
- eyeWidgets[key].lookY.set(state.face.eyes[key].lookY ?? 0);
- eyeWidgets[key].lowerLid.set(state.face.eyes[key].lowerLid ?? state.face.eyes.lowerLid ?? 0);
+ 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);
}
- irisShapeSegment.set(state.face.eyes.left.irisShape ?? 'circle');
- lookPad.set({ x: state.face.eyes.left.lookX, y: state.face.eyes.left.lookY });
+ 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);
lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve');
lidTiltSlider.set(state.face.eyes.lidTilt ?? 0);
- hideWhiteToggle.set(state.face.eyes.hideWhite === true);
browToggle.set(state.face.eyes.brow.enabled);
browSliders.angle.set(state.face.eyes.brow.angle);
browSliders.height.set(state.face.eyes.brow.height);
@@ -2704,6 +2786,12 @@ export function buildPanel(app, root) {
browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0);
browSliders.curve.set(state.face.eyes.brow.curve);
browSliders.taper.set(state.face.eyes.brow.taper ?? 1);
+ // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather
+ // than leave them looking like they should do something.
+ const browIsImage = state.face.eyes.brow.image === true;
+ for (const key of ['angle', 'thickness', 'curve', 'taper']) {
+ setRowEnabled(browSliders[key], !browIsImage);
+ }
browColor.set(state.face.eyes.brow.color);
glassesToggle.set(state.face.eyes.glasses.enabled === true);
glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses');
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 7f091f1..5ba46af 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -194,12 +194,14 @@ export function defaultState() {
},
face: {
// 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
- hat: { kind: 'none' },
+ // `height` lifts it off the head; `tiltX` leans it forward/back and `tiltZ`
+ // leans it sideways.
+ hat: { kind: 'none', height: 0, tiltX: 0, tiltZ: 0 },
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 },
- 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 },
+ 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 },
irisScale: 1,
lookMax: 1.2,
highlight: true,
@@ -210,12 +212,8 @@ export function defaultState() {
// narrowed eye. See faceArt.js.
lidShape: 'curve',
lidTilt: 0,
- // Hide the eyeball's white everywhere (not only for a heart): a line
- // squint then reads as a bare line.
- hideWhite: false,
heartScale: 2,
heartColor: '#e0344f',
- heartHideWhite: true,
// The tears live on each eye (see `eyes.left.tear`), so one eye can cry
// on its own; only the colour is shared.
tearColor: '#8fd8ff',
@@ -511,11 +509,10 @@ export const FACE_PRESETS = [
label: 'すき',
face: {
eyes: {
- left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' },
- right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' },
+ 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 },
irisScale: 1,
// The white behind a heart would poke out around its lobes, so hide it.
- hideWhite: true,
heartColor: '#e0344f',
highlight: false,
},
diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css
index bb76265..ffcbac3 100644
--- a/public/bluebey-studio/src/style.css
+++ b/public/bluebey-studio/src/style.css
@@ -336,6 +336,8 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
.control { display: flex; align-items: center; gap: 7px; min-width: 0; }
.hint { font-size: 11px; line-height: 1.55; color: var(--muted); }
.hint b { color: #5a4a7d; }
+.row.is-off .control, .row.is-off > .label { opacity: 0.4; }
+.row.is-off .control input { cursor: default; }
.subhead {
font-size: 10.5px;
font-weight: 700;