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/face.js5
-rw-r--r--public/bluebey-studio/src/faceArt.js187
-rw-r--r--public/bluebey-studio/src/main.js169
-rw-r--r--public/bluebey-studio/src/panel.js69
-rw-r--r--public/bluebey-studio/src/presets.js21
-rw-r--r--public/bluebey-studio/src/rig.js54
6 files changed, 350 insertions, 155 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index 196ff84..6898faf 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -197,8 +197,9 @@ export class Face {
// the other shared eye fields (see `drawEyes` in src/faceArt.js).
cheeks: p.eyes.cheeks,
headMark: p.eyes.headMark,
- // 鼻ちょうちん is shared by the whole face as well.
- snot: p.eyes.snot,
+ // ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D
+ // object hung off the nose - see src/main.js - so it is not drawn here.)
+ beard: p.eyes.beard,
// 眼鏡 / サングラス are shared by both eyes and drawn into this same
// texture, so they travel with the other shared eye fields.
glasses: p.eyes.glasses,
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 1beedad..a92482a 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -178,19 +178,14 @@ export const HEAD_MARK_LAYOUT = {
};
/**
- * 鼻ちょうちん: the little snot bubble that grows and shrinks while someone sleeps.
- *
- * It hangs off the nose, which is a *separate mesh* in front of the plate, so the
- * bubble sits out to the side of it (a bubble drawn straight below would run off
- * the plate's bottom edge). The neck starts at the nose and reaches out to the
- * bubble, one thin wisp, the way manga draws it.
+ * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate
+ * at the nose, the same spot the snot bubble uses. `shape` picks a well-known
+ * kind; `size` scales it about the nose, and the colours are adjustable.
*/
-export const SNOT_LAYOUT = {
- cx: 511.5, // the face midline, the same centre the mouth uses
- cy: 336, // just below the eye row, level with the nose
- offset: 122, // how far out to the side the bubble hangs, clear of the nose
- radius: 48, // bubble radius at size 1
- lineWidth: 6,
+export const BEARD_LAYOUT = {
+ cx: 511.5,
+ cy: 352, // just under the nose
+ width: 240, // base width of the widest beard at size 1
};
/**
@@ -771,7 +766,7 @@ function drawHeadCover(ctx, spec, limits) {
const shape = spec?.shape ?? 'off';
if (shape === 'off' || shape === 'none') return;
- const size = clamp(spec.size ?? 1, 0.2, 3);
+ const size = clamp(spec.size ?? 1, 0.02, 3);
const left = limits.left;
const right = limits.right;
const top = limits.top;
@@ -788,28 +783,8 @@ function drawHeadCover(ctx, spec, limits) {
ctx.clip();
if (shape === 'split') {
- // Two-tone head: a vertical curve divides the head into two colours. `size`
- // bows the seam, `offsetY` bows it the other way, `offsetX` slides it.
- const bow = (size - 1) * 220 + clamp(spec.offsetY ?? 0, -600, 600);
- const seam = (y) => cx + Math.sin(clamp((y - top) / (bottom - top), 0, 1) * Math.PI) * bow;
- const steps = 32;
- const trace = (dir) => {
- ctx.beginPath();
- ctx.moveTo(seam(top), top);
- for (let i = 1; i <= steps; i += 1) {
- const y = top + ((bottom - top) * i) / steps;
- ctx.lineTo(seam(y), y);
- }
- ctx.lineTo(dir > 0 ? right : left, bottom);
- ctx.lineTo(dir > 0 ? right : left, top);
- ctx.closePath();
- };
- trace(-1);
- ctx.fillStyle = color;
- ctx.fill();
- trace(1);
- ctx.fillStyle = color2;
- ctx.fill();
+ // Retired: the two-tone head was dropped, but an old saved state may still
+ // carry the shape, so treat it as off rather than drawing a two-tone head.
ctx.restore();
return;
}
@@ -825,6 +800,11 @@ function drawHeadCover(ctx, spec, limits) {
// Bangs: a soft point dipping in the middle, with side lobes.
return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI));
}
+ if (shape === 'fringe-up') {
+ // The mirror image: a fringe that peaks *up* in the middle (a widow's peak
+ // or centre part), so more forehead shows in the middle than at the sides.
+ return base - 150 * (1 - Math.abs(t)) + 55 * Math.abs(Math.sin(t * Math.PI));
+ }
if (shape === 'wave') {
// A single smooth sweep across the forehead.
return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5);
@@ -848,6 +828,23 @@ function drawHeadCover(ctx, spec, limits) {
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
+
+ if (shape === 'side') {
+ // わけめ: cut a real parting in the hair, so the two halves read as combed
+ // apart rather than as one smooth sweep.
+ const partX = cx - halfSpan * 0.45;
+ const gw = Math.max(5, width * 0.02) * (0.6 + size);
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.beginPath();
+ ctx.moveTo(partX - gw * 0.16, top);
+ ctx.lineTo(partX + gw * 0.16, top);
+ ctx.lineTo(partX + gw * 0.7, boundary(partX) + 4);
+ ctx.lineTo(partX - gw * 0.7, boundary(partX) + 4);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
ctx.restore();
}
@@ -859,73 +856,77 @@ function drawFaceExtras(ctx, style, lineOnly) {
drawCheeks(ctx, style.cheeks);
// The head covering fills out to the plate's real edges, so it needs `limits`.
drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS);
- drawSnotBubble(ctx, style.snot, lineOnly);
+ drawBeard(ctx, style.beard, lineOnly);
}
/**
- * 鼻ちょうちん: a bubble hanging off the nose, joined to it by a thin neck. The
- * neck starts at the nose (which the nose mesh hides, so it reads as growing out
- * from under it) and swells into the round bubble out to the side. In a line
- * drawing the bubble is left empty and only its outline is inked.
+ * ひげ: a mustache or beard under the nose. Each kind is a small shape built on
+ * the spot; `scotch` and `cat` are drawn with round-capped strokes, `kaiser` is a
+ * thick waving band, and `apron` is a filled bib. In a line drawing nothing is
+ * filled and only the outlines are inked.
*/
-function drawSnotBubble(ctx, spec, lineOnly) {
- if (!spec?.enabled) return;
+function drawBeard(ctx, spec, lineOnly = false) {
+ const shape = spec?.shape ?? 'off';
+ if (shape === 'off' || shape === 'none') return;
const size = clamp(spec.size ?? 1, 0.2, 3);
- const dx = spec.offsetX ?? 0;
- const dy = spec.offsetY ?? 0;
- const r = SNOT_LAYOUT.radius * size;
- const cx = SNOT_LAYOUT.cx + SNOT_LAYOUT.offset + dx;
- const cy = SNOT_LAYOUT.cy + dy;
- const color = spec.color ?? '#e3ecff';
+ const cx = BEARD_LAYOUT.cx + clamp(spec.offsetX ?? 0, -600, 600);
+ const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600);
+ const w = BEARD_LAYOUT.width * size;
+ const color = spec.color ?? '#3a2a4a';
const line = spec.line ?? '#55386e';
- const lw = Math.max(1, SNOT_LAYOUT.lineWidth * size);
-
- // The nose end sits on the midline; the bubble hangs out to one side.
- const nx = SNOT_LAYOUT.cx + dx;
- const ny = SNOT_LAYOUT.cy + dy - r * 0.1;
- const dist = Math.hypot(cx - nx, cy - ny) || 1;
- const ux = (cx - nx) / dist;
- const uy = (cy - ny) / dist;
- // Where the neck meets the bubble: a little inside its edge, so the two merge.
- const reach = Math.max(0, dist - r * 0.72);
- const ex = nx + ux * reach;
- const ey = ny + uy * reach;
+ const lw = Math.max(2, 9 * size);
+ const stroke = (points, width) => strokePolyline(ctx, points, {
+ color: lineOnly ? line : color, width,
+ });
- ctx.save();
- ctx.lineJoin = 'round';
- ctx.lineCap = 'round';
- // The neck: a wedge from a narrow end at the nose to a wide end at the bubble.
- const px = -uy;
- const py = ux;
- const nW = lw * 0.55;
- const bW = r * 0.5;
- ctx.beginPath();
- ctx.moveTo(nx + px * nW, ny + py * nW);
- ctx.lineTo(ex + px * bW, ey + py * bW);
- ctx.lineTo(ex - px * bW, ey - py * bW);
- ctx.lineTo(nx - px * nW, ny - py * nW);
- ctx.closePath();
- if (lineOnly) {
- ctx.strokeStyle = line;
- ctx.lineWidth = lw;
- ctx.stroke();
- } else {
- ctx.fillStyle = color;
- ctx.fill();
- ctx.strokeStyle = line;
- ctx.lineWidth = lw * 0.7;
- ctx.stroke();
+ if (shape === 'scotch') {
+ // ちょび髭: two short dashes under the nose, angled down and out so their tips
+ // clear the nose mesh (which hides anything drawn straight behind it).
+ stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
+ stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
+ return;
}
- // The bubble itself, over the neck's wide end.
- circlePath(ctx, cx, cy, r);
- if (!lineOnly) {
- ctx.fillStyle = color;
- ctx.fill();
+ if (shape === 'kaiser') {
+ // カイゼル: a full, thick mustache whose ends turn up.
+ stroke([
+ { x: cx - w * 0.5, y: cy - w * 0.02 },
+ { x: cx - w * 0.28, y: cy + w * 0.07 },
+ { x: cx, y: cy + w * 0.09 },
+ { x: cx + w * 0.28, y: cy + w * 0.07 },
+ { x: cx + w * 0.5, y: cy - w * 0.02 },
+ ], lw * 2.6);
+ stroke([{ x: cx - w * 0.5, y: cy - w * 0.02 }, { x: cx - w * 0.62, y: cy - w * 0.16 }], lw * 1.4);
+ stroke([{ x: cx + w * 0.5, y: cy - w * 0.02 }, { x: cx + w * 0.62, y: cy - w * 0.16 }], lw * 1.4);
+ return;
+ }
+ if (shape === 'apron') {
+ // エプロン: a bib-shaped beard hanging from the cheeks to a rounded point.
+ const pts = [
+ { x: cx - w * 0.6, y: cy - w * 0.04 },
+ { x: cx - w * 0.5, y: cy + w * 0.35 },
+ { x: cx - w * 0.22, y: cy + w * 0.62 },
+ { x: cx, y: cy + w * 0.7 },
+ { x: cx + w * 0.22, y: cy + w * 0.62 },
+ { x: cx + w * 0.5, y: cy + w * 0.35 },
+ { x: cx + w * 0.6, y: cy - w * 0.04 },
+ ];
+ if (lineOnly) {
+ strokePolyline(ctx, pts, { color: line, width: lw });
+ } else {
+ fillPolygon(ctx, pts, color);
+ strokePolyline(ctx, pts, { color: line, width: lw * 0.5 });
+ }
+ return;
+ }
+ // ねこ: three whiskers fanned out on each side of the muzzle.
+ for (const dir of [-1, 1]) {
+ for (let i = 0; i < 3; i += 1) {
+ stroke([
+ { x: cx + dir * w * 0.08, y: cy + w * 0.04 },
+ { x: cx + dir * w * 0.62, y: cy + w * 0.04 + (i - 1) * w * 0.18 },
+ ], lw * 0.9);
+ }
}
- ctx.strokeStyle = line;
- ctx.lineWidth = lw;
- ctx.stroke();
- ctx.restore();
}
/**
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 9c0b98d..68df32c 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -265,6 +265,79 @@ async function init() {
const propRoot = new THREE.Group();
scene.add(propRoot);
+ // --- 鼻ちょうちん: a modelled bubble hung under the nose --------------------
+ // The nose is a *skinned* part of the body, so a child of the nose mesh would
+ // sit at the origin instead of on the face. The whole body (nose included)
+ // rides the `master` bone, so the bubble is parented there, placed at the nose's
+ // rest position in that bone's local frame, and read from the face state each
+ // frame in the render loop.
+ function applySnotBubble(group, snot) {
+ if (!group) return;
+ group.visible = snot?.enabled === true;
+ if (!group.visible) return;
+ group.scale.setScalar(Number.isFinite(snot.size) ? snot.size : 1);
+ const base = group.userData.base;
+ const k = 0.01; // artwork px -> world units, enough to nudge the bubble
+ group.position.set(
+ base.x + (snot.offsetX ?? 0) * k,
+ base.y + (snot.offsetY ?? 0) * k,
+ base.z,
+ );
+ group.userData.material.color.set(snot.color ?? '#dfe8ff');
+ }
+
+ const snotBubble = (() => {
+ const nose = model.parts.noseMesh;
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!nose || !master || !nose.isSkinnedMesh) return null;
+ model.root.updateMatrixWorld(true);
+ // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the
+ // back of the model, in fact) - sample the skinned positions instead, which is
+ // where the nose actually ends up.
+ const attr = nose.geometry.attributes.position;
+ const step = Math.max(1, Math.floor(attr.count / 400));
+ const point = new THREE.Vector3();
+ const centre = new THREE.Vector3();
+ let samples = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ centre.add(point);
+ samples += 1;
+ }
+ if (!samples) return null;
+ centre.multiplyScalar(1 / samples);
+ nose.localToWorld(centre);
+ let radius = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ nose.localToWorld(point);
+ radius = Math.max(radius, point.distanceTo(centre));
+ }
+ if (!Number.isFinite(radius) || radius <= 0) radius = 0.25;
+ const material = new THREE.MeshStandardMaterial({
+ color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92,
+ });
+ const group = new THREE.Group();
+ group.name = 'snot-bubble';
+ const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.05, 28, 20), material);
+ const neck = new THREE.Mesh(new THREE.SphereGeometry(radius * 0.42, 20, 14), material);
+ group.add(bubble, neck);
+ const bone = master.bone;
+ const bubbleLocal = bone.worldToLocal(centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7)));
+ const noseLocal = bone.worldToLocal(centre.clone());
+ group.position.copy(bubbleLocal);
+ // The neck lump sits partway back towards the nose, so the two read as joined.
+ neck.position.copy(noseLocal).sub(bubbleLocal).multiplyScalar(0.45);
+ group.userData = { bubble, neck, material, base: bubbleLocal.clone() };
+ group.visible = false;
+ bone.add(group);
+ return group;
+ })();
+ const snotMeshes = snotBubble ? [...snotBubble.children] : [];
+ applySnotBubble(snotBubble, state.face.eyes.snot);
+
const halfHeight = model.size.y * 0.5;
view.frame(model.size, halfHeight);
@@ -975,6 +1048,7 @@ async function init() {
rig.helper,
model.parts.eyeMesh,
model.parts.mouthMesh,
+ ...snotMeshes,
...styles.outlineMeshes,
];
}
@@ -1156,6 +1230,7 @@ async function init() {
let fpsAt = performance.now();
const warmupUntil = performance.now() + 2500;
+ let snotSignature = null;
const loop = (now) => {
const rawDelta = now - last;
const dt = Math.min(0.05, rawDelta / 1000);
@@ -1167,6 +1242,16 @@ async function init() {
if (!rig.controls.dragging) rig.applyPose(posed);
container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0);
+ if (snotBubble) {
+ const snot = state.face.eyes.snot;
+ const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.color}`;
+ if (sig !== snotSignature) {
+ snotSignature = sig;
+ applySnotBubble(snotBubble, snot);
+ app.needsRender = true;
+ }
+ }
+
const params = buildFaceParams();
const signature = JSON.stringify(params);
if (signature !== faceSignature) {
@@ -2069,6 +2154,90 @@ async function init() {
document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); });
const help = document.getElementById('help');
help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); });
+
+ // --- the mouse on the character ----------------------------------------
+ // Dragging the model slides it across the view; holding Shift and dragging
+ // turns it around. Bone posing lives in the panel, so the mouse never grabs a
+ // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over
+ // the view, so a tap on the view first just dismisses the sheet.
+ const dragRay = new THREE.Raycaster();
+ const dragNdc = new THREE.Vector2();
+ const dragHitAt = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ dragNdc.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ dragRay.setFromCamera(dragNdc, view.camera);
+ const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body]
+ .filter((mesh) => mesh?.visible);
+ return dragRay.intersectObjects(targets, false)[0] ?? null;
+ };
+ // World units per screen pixel, so the model keeps up with the cursor.
+ const dragScale = () => {
+ const camera = view.camera;
+ const height = Math.max(1, canvas.clientHeight);
+ if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height;
+ const distance = camera.position.distanceTo(view.controls.target);
+ return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height;
+ };
+ let modelDrag = null;
+
+ canvas.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0 || event.target !== canvas) return;
+ if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) {
+ togglePanel();
+ return;
+ }
+ if (!dragHitAt(event)) return;
+ const root = state.pose.root ?? [0, 0, 0];
+ const master = state.pose.bones?.master ?? [0, 0, 0];
+ modelDrag = {
+ pointerId: event.pointerId,
+ spin: event.shiftKey,
+ x: event.clientX,
+ y: event.clientY,
+ root: [...root],
+ masterY: master[1] ?? 0,
+ };
+ view.controls.enabled = false;
+ stage.style.cursor = 'grabbing';
+ try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ }
+ });
+
+ canvas.addEventListener('pointermove', (event) => {
+ if (!modelDrag || event.pointerId !== modelDrag.pointerId) return;
+ const dx = event.clientX - modelDrag.x;
+ const dy = event.clientY - modelDrag.y;
+ if (modelDrag.spin) {
+ const bones = { ...(state.pose.bones ?? {}) };
+ const master = bones.master ?? [0, 0, 0];
+ bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]];
+ state.pose.bones = bones;
+ } else {
+ const k = dragScale();
+ state.pose.root = [
+ round2(modelDrag.root[0] + dx * k),
+ round2(modelDrag.root[1] - dy * k),
+ modelDrag.root[2],
+ ];
+ }
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ });
+
+ const endModelDrag = (event) => {
+ if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return;
+ modelDrag = null;
+ view.controls.enabled = true;
+ stage.style.cursor = '';
+ if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } }
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ };
+ canvas.addEventListener('pointerup', endModelDrag);
+ canvas.addEventListener('pointercancel', endModelDrag);
}
function togglePanel() {
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index bad4059..c26c88c 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -201,7 +201,8 @@ export function buildPanel(app, root) {
app.needsRender = true;
}
- poseSection.add(hint('モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。スライダーは「休めの姿勢からの差」です。'));
+ poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。'
+ + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。'));
// こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the
// main way in, so they stay folded away inside the bone section.
@@ -246,10 +247,19 @@ export function buildPanel(app, root) {
? POSE_PRESETS.find((item) => item.id === presetOrId)
: presetOrId;
if (!preset) return;
+ // Some poses already raise the arms; leaving the てをふる motion running would
+ // add to the arm the pose just set and fold the flipper over the face, so
+ // switching to any pose stops that motion.
+ if (state.anim.mode === 'wave') {
+ state.anim.mode = 'off';
+ app.animator.reset();
+ app.needsRender = true;
+ }
state.pose = { bones: {}, root: [0, 0, 0] };
applyPatch(state.pose, preset.pose ?? {});
if (!state.pose.root) state.pose.root = [0, 0, 0];
replace({}, 'pose');
+ sync();
}
function randomPose() {
@@ -622,7 +632,7 @@ export function buildPanel(app, root) {
onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); },
}),
length: slider({
- label: '長さ', min: 0, max: 1.6, step: 0.01, value: 1,
+ label: '長さ', min: 0, max: 4, step: 0.01, value: 1,
format: (v) => (v < 0.02 ? '点' : v.toFixed(2)),
onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); },
}),
@@ -763,6 +773,10 @@ export function buildPanel(app, root) {
value: 'glasses',
onChange: (value) => {
setGlassesKind(value);
+ // Picking a kind means you mean to wear it, so put the glasses on rather
+ // than leaving the checkbox off and nothing showing on the face.
+ state.face.eyes.glasses.enabled = true;
+ glassesToggle.set(true);
app.actions.refresh('face');
sync();
},
@@ -884,9 +898,9 @@ export function buildPanel(app, root) {
{ value: 'off', label: 'なし' },
{ value: 'cap', label: 'かぶせ' },
{ value: 'fringe', label: 'まえがみ' },
+ { value: 'fringe-up', label: 'まえがみ(逆)' },
{ value: 'wave', label: 'なみ' },
{ value: 'side', label: 'わけめ' },
- { value: 'split', label: 'ツートン' },
],
value: 'off',
onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); },
@@ -895,7 +909,7 @@ export function buildPanel(app, root) {
const headMarkSliders = {
size: slider({
- label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); },
}),
offsetX: slider({
@@ -924,7 +938,8 @@ export function buildPanel(app, root) {
headMarkBox.add(headMarkColor.el);
headMarkBox.add(headMarkColor2.el);
headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。'
- + '「ツートン」は頭を左右2色に分けます(2色目を使います)。'
+ + '「まえがみ(逆)」は、まえがみを上下さかさまにした形です。'
+ + '「わけめ」は、髪を分けた筋が入ります。'
+ '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。'));
// --- 鼻ちょうちん: the snot bubble of a sleeping face.
@@ -962,6 +977,47 @@ export function buildPanel(app, root) {
+ '「ねている」ポーズと組み合わせると、いねむりしているように見えます。'
+ '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。'));
+ // --- ひげ: mustaches and beards, hung under the nose.
+ faceSection = section(tabFace, 'ひげ');
+ const beardShape = segmented({
+ label: 'ひげ',
+ options: [
+ { value: 'off', label: 'なし' },
+ { value: 'scotch', label: 'ちょびひげ' },
+ { value: 'kaiser', label: 'カイゼル' },
+ { value: 'apron', label: 'エプロン' },
+ { value: 'cat', label: 'ねこ' },
+ ],
+ value: 'off',
+ onChange: (value) => { state.face.eyes.beard.shape = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(beardShape.el);
+ const beardSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); },
+ }),
+ offsetX: slider({
+ label: 'よこの位置', min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.beard.offsetX = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(beardSliders.size.el);
+ const beardBox = details(faceSection.body, 'こまかい設定(位置・色)');
+ beardBox.add(beardSliders.offsetX.el);
+ beardBox.add(beardSliders.offsetY.el);
+ const beardColor = colorField({
+ label: 'ひげの色', value: '#3a2a4a',
+ swatches: ['#3a2a4a', '#1c1624', '#6b4a2a', '#8a8a94'],
+ onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); },
+ });
+ beardBox.add(beardColor.el);
+ beardBox.add(hint('鼻の下に、ちょびひげ・カイゼル・エプロン・ねこ、の髭を描きます。'));
+
// --- mouth
faceSection = section(tabFace, '口');
@@ -2394,6 +2450,9 @@ export function buildPanel(app, root) {
snotSize.set(state.face.eyes.snot.size);
for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]);
snotColor.set(state.face.eyes.snot.color);
+ beardShape.set(state.face.eyes.beard.shape ?? 'off');
+ for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]);
+ beardColor.set(state.face.eyes.beard.color);
});
syncers.push(() => {
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index d967af2..a450eb2 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -219,6 +219,15 @@ export function defaultState() {
color: '#e3ecff',
line: '#55386e',
},
+ // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
+ // `scotch`, `kaiser`, `apron` or `cat`. Starts off.
+ beard: {
+ shape: 'off',
+ size: 1,
+ offsetX: 0,
+ offsetY: 0,
+ color: '#3a2a4a',
+ },
// Eyebrows are drawn into the eye texture. Off by default, because the
// original character has no eyebrows.
brow: {
@@ -638,13 +647,15 @@ export const POSE_PRESETS = [
label: 'ねている',
pose: {
bones: {
- // Rolled right over onto its side (Z is the sideways roll) - the body lies
- // along the ground with the face turned up towards the camera.
- master: [0, 0, 88],
+ // Flat on its back: X tips the body backwards all the way over, so the
+ // face looks up. The root lift keeps the back resting on the ground
+ // rather than sinking through it (the body's depth becomes its height).
+ master: [-88, 0, 0],
// Both flippers rest against the body, loose.
- 'arm.l': [-30, 0, 0],
- 'arm.r': [-30, 0, 0],
+ 'arm.l': [-18, 0, 0],
+ 'arm.r': [-18, 0, 0],
},
+ root: [0, 1.5, 0],
},
},
{ id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } },
diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js
index 2922dfa..e06c337 100644
--- a/public/bluebey-studio/src/rig.js
+++ b/public/bluebey-studio/src/rig.js
@@ -51,14 +51,6 @@ export class Rig {
this.controls.addEventListener('objectChange', () => {
if (this.selected) this.onChange?.(this.selected);
});
-
- this.raycaster = new THREE.Raycaster();
- this.pointer = new THREE.Vector2();
- this.pressAt = null;
- this.onPointerDown = this.handlePointerDown.bind(this);
- this.onPointerUp = this.handlePointerUp.bind(this);
- domElement.addEventListener('pointerdown', this.onPointerDown);
- domElement.addEventListener('pointerup', this.onPointerUp);
}
/* ------------------------------------------------------------ selection */
@@ -76,46 +68,10 @@ export class Rig {
if (!silent) this.onChange?.(this.selected);
}
- selectAt(event) {
- const rect = this.domElement.getBoundingClientRect();
- this.pointer.set(
- ((event.clientX - rect.left) / rect.width) * 2 - 1,
- -((event.clientY - rect.top) / rect.height) * 2 + 1,
- );
- this.raycaster.setFromCamera(this.pointer, this.controls.camera);
-
- const targets = this.pickTargets().filter((mesh) => mesh.visible);
- const hits = this.raycaster.intersectObjects(targets, false);
- if (!hits.length) return null;
-
- const point = hits[0].point;
- let best = null;
- let bestDistance = Infinity;
- const world = new THREE.Vector3();
- for (const entry of this.bones) {
- entry.bone.getWorldPosition(world);
- const distance = world.distanceToSquared(point);
- if (distance < bestDistance) {
- bestDistance = distance;
- best = entry.name;
- }
- }
- if (best) this.select(best);
- return best;
- }
-
- handlePointerDown(event) {
- // Remember where the press started so a drag never changes the selection.
- this.pressAt = { x: event.clientX, y: event.clientY, axis: this.controls.axis };
- }
-
- handlePointerUp(event) {
- const press = this.pressAt;
- this.pressAt = null;
- if (!press || press.axis) return; // pressed on the gizmo, not the model
- if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4) return;
- this.selectAt(event);
- }
+ // Clicking the model no longer reaches for a bone: the mouse is for composing
+ // (see the drag handling in src/main.js), and bones are picked from the panel's
+ // bone list, which calls `select()` directly. So there is no pointer handler on
+ // the canvas here at all.
/* ----------------------------------------------------------------- pose */
@@ -185,8 +141,6 @@ export class Rig {
}
dispose() {
- this.domElement.removeEventListener('pointerdown', this.onPointerDown);
- this.domElement.removeEventListener('pointerup', this.onPointerUp);
this.controls.detach();
this.controls.dispose();
this.helper.removeFromParent();