From 8f39a174d6c4415c7a4df418187090fb5e061983 Mon Sep 17 00:00:00 2001
From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>
Date: Tue, 6 Oct 2026 09:56:21 +0900
Subject: bluebey-studio:
ボーンのたて回転・縦書きと吹き出しの形・線画の帽子小物・光の色
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ボーンのたてスライダーが±180°で暴れる問題を修正。セリフの縦書きを禁則込みで直し、「ささやき」「思考」の吹き出しを追加。帽子・小物の輪郭線を線画のみにし、線画で帯や芯の境目が読めるよう調整。眼鏡の左右レンズ間隔、光と環境光の色を追加。ハートの下まぶた、レンズが濃いときのまばたきを修正。口メニューの項目名を整理。「顔/すべてをランダム」に眼鏡・Oの口・ひげの大きさ・目の左右位置などを追加。
---
public/bluebey-studio/src/caption.js | 239 ++++++++++++++++++++++++++++++++---
public/bluebey-studio/src/faceArt.js | 38 ++++--
public/bluebey-studio/src/hats.js | 29 +++--
public/bluebey-studio/src/main.js | 11 +-
public/bluebey-studio/src/panel.js | 104 ++++++++++++---
public/bluebey-studio/src/presets.js | 17 ++-
public/bluebey-studio/src/props.js | 34 +++--
public/bluebey-studio/src/rig.js | 56 +++++++-
public/bluebey-studio/src/styles.js | 60 +++++++--
9 files changed, 506 insertions(+), 82 deletions(-)
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js
index a315971..fd418da 100644
--- a/public/bluebey-studio/src/caption.js
+++ b/public/bluebey-studio/src/caption.js
@@ -70,6 +70,23 @@ const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
/** Small kana, which sit toward the upper-right of their cell when stacked. */
const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ';
+/**
+ * Characters that may not open a column (行頭禁則).
+ *
+ * A closing mark such as `、` simply hangs off the previous column; `ー`, small
+ * kana and closing brackets are kept company by handing the preceding
+ * character over to the next column as well, so a column never starts with
+ * something that has nothing to attach to.
+ */
+const COLUMN_START_FORBIDDEN = new Set([
+ '」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー',
+ ...SMALL_KANA,
+ ...HANGING_PUNCTUATION,
+]);
+
+/** Characters that may not close a column (行末禁則). */
+const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']);
+
/**
* Per-character offset for vertical (縦書き) text, in em.
*
@@ -270,6 +287,10 @@ export function drawCaption(ctx, caption, { width, height, scale = 1, font = nul
ctx.lineWidth = borderWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
+ // A whisper is the rounded bubble with a broken outline.
+ if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') {
+ ctx.setLineDash([borderWidth * 3, borderWidth * 2]);
+ }
ctx.stroke();
}
}
@@ -332,6 +353,9 @@ export function captionToSvg(caption, { width, height, scale = 1, font = null, r
const stroke = borderWidth > 0
? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"`
+ ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"`
+ + (bubbleOf(state) === 'whisper'
+ ? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"`
+ : '')
: '';
parts.push(``);
}
@@ -528,6 +552,10 @@ function verticalOffsetOf(ch) {
* the column's length, so it never makes the block a cell taller or adds a
* column of its own. `lines[i].hang` reports how many characters on that line
* are hanging.
+ *
+ * The other 禁則 cases are repaired too: ー, small kana and closing brackets are
+ * kept off the top of a column by handing the preceding character down with
+ * them (追い出し), and an opening bracket is never left at the bottom of one.
*/
function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
const limit = Number.isFinite(maxHeight) && maxHeight > 0
@@ -540,23 +568,36 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
columns.push({ text: '', count: 0 });
continue;
}
- let current = '';
- let count = 0;
- for (const ch of chars) {
- if (count >= limit) {
- // A closing mark that would start a column hangs off the previous one.
- if (HANGING_PUNCTUATION.has(ch)) {
- current += ch;
- continue;
- }
- columns.push({ text: current, count });
- current = '';
- count = 0;
+ let i = 0;
+ while (i < chars.length) {
+ // Fill the column cell by cell, then repair its two ends for 禁則処理.
+ const cells = [];
+ const hanging = [];
+ while (i < chars.length && cells.length < limit) {
+ cells.push(chars[i]);
+ i += 1;
+ }
+ // 行末禁則: an opening bracket may not close a column, so hand it over to
+ // the next one (it will open that column instead).
+ while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) {
+ cells.pop();
+ i -= 1;
+ }
+ // 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a
+ // column; move the preceding character down to keep them company.
+ if (i < chars.length && cells.length > 1
+ && COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) {
+ cells.pop();
+ i -= 1;
+ }
+ // 行頭禁則 (ぶら下げ): a closing mark that would open the next column
+ // stays on this one, drawn just below its last cell.
+ while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) {
+ hanging.push(chars[i]);
+ i += 1;
}
- current += ch;
- count += 1;
+ columns.push({ text: cells.join('') + hanging.join(''), count: cells.length });
}
- columns.push({ text: current, count });
}
const longest = columns.reduce((max, column) => Math.max(max, column.count), 0);
return {
@@ -565,7 +606,12 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
width: fontSize,
hang: [...column.text].length - column.count,
})),
- width: columns.length * fontSize * lineHeight,
+ // The block is exactly as wide as the columns that are drawn: `step` apart,
+ // each cell one em wide (not one whole `step`, which would leave a leading
+ // of dead space on the right and push the text off-centre).
+ width: columns.length === 0
+ ? 0
+ : (columns.length - 1) * fontSize * lineHeight + fontSize,
height: longest * fontSize,
vertical: true,
};
@@ -644,6 +690,8 @@ function bubbleCommands(box, state, scale) {
};
if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return [];
+ if (shape === 'think') return thinkCommands(rect, state, scale);
+
const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale;
const edges = shape === 'shout'
? shoutEdges(rect, scale)
@@ -728,6 +776,162 @@ function shoutEdges(box, scale) {
return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length]));
}
+/**
+ * Cloud outline for a thought bubble, plus how far its scallops bulge out.
+ *
+ * The four sides of the box are replaced by a ring of outward scallops: a
+ * rounded rectangle is sampled evenly by arc length and each pair of samples is
+ * joined by a half-ellipse bulging away from the centre.
+ */
+function thinkEdges(box, scale) {
+ const { x, y, w, h } = box;
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const ring = roundedRectRing(box, Math.min(w, h) * 0.28);
+ const perimeter = ringLength(ring);
+ const pitch = Math.max(scale * 8, Math.min(w, h) * 0.42);
+ const count = Math.max(6, Math.round(perimeter / pitch));
+ const points = sampleRing(ring, count);
+ const amp = Math.min((perimeter / count) * 0.5, Math.min(w, h) * 0.18);
+
+ const edges = [];
+ for (let i = 0; i < points.length; i++) {
+ const a = points[i];
+ const b = points[(i + 1) % points.length];
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ const nx = mid.x - cx;
+ const ny = mid.y - cy;
+ const length = Math.hypot(nx, ny) || 1;
+ edges.push(scallopEdge(a, b, { x: nx / length, y: ny / length }, amp));
+ }
+ return { edges, amp };
+}
+
+/** A closed cloud, followed by the tail: two or three shrinking puffs. */
+function thinkCommands(box, state, scale) {
+ const cloud = thinkEdges(box, scale);
+ const commands = edgesToCommands(cloud.edges);
+ for (const puff of thoughtTrail(box, state, scale, cloud.amp)) {
+ commands.push(...circleCommands(puff.x, puff.y, puff.r));
+ }
+ return commands;
+}
+
+/**
+ * The circles that trail away from a thought bubble instead of a pointed tail.
+ *
+ * They start just outside the cloud (`outer`) and shrink as they go, ending
+ * inside the room `layoutCaption` reserves for the tail.
+ */
+function thoughtTrail(box, state, scale, outer) {
+ const tail = tailOf(state);
+ if (tail === 'none') return [];
+ const dir = tailDirection(tail);
+ const diagonal = dir.x !== 0 && dir.y !== 0;
+ const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1);
+ const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1);
+ const anchor = {
+ x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
+ y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
+ };
+ const span = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5);
+ const room = span - outer;
+ if (!(room > 0)) return [];
+
+ const r1 = room * 0.22;
+ const r2 = room * 0.13;
+ const r3 = room * 0.07;
+ const gap = room * 0.05;
+ const d1 = outer + r1;
+ const d2 = d1 + r1 + gap + r2;
+ const d3 = d2 + r2 + gap + r3;
+ return [
+ { x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 },
+ { x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 },
+ { x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 },
+ ];
+}
+
+/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */
+function scallopEdge(from, to, normal, amp) {
+ const k = (4 / 3) * amp;
+ return cubicEdge(
+ from,
+ { x: from.x + normal.x * k, y: from.y + normal.y * k },
+ { x: to.x + normal.x * k, y: to.y + normal.y * k },
+ to,
+ );
+}
+
+/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */
+function circleCommands(cx, cy, r) {
+ const k = r * KAPPA;
+ return [
+ { type: 'M', x: cx + r, y: cy },
+ { type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r },
+ { type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy },
+ { type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r },
+ { type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy },
+ { type: 'Z' },
+ ];
+}
+
+/** Points around a rounded rectangle, clockwise from the top-left arc. */
+function roundedRectRing(box, radius) {
+ const { x, y, w, h } = box;
+ const r = clamp(radius, 0, Math.min(w, h) / 2);
+ const points = [];
+ const arc = (cx, cy, from, to) => {
+ const steps = 6;
+ for (let i = 1; i <= steps; i++) {
+ const t = from + (to - from) * (i / steps);
+ points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });
+ }
+ };
+
+ points.push({ x: x + r, y });
+ points.push({ x: x + w - r, y });
+ arc(x + w - r, y + r, -Math.PI / 2, 0);
+ points.push({ x: x + w, y: y + h - r });
+ arc(x + w - r, y + h - r, 0, Math.PI / 2);
+ points.push({ x: x + r, y: y + h });
+ arc(x + r, y + h - r, Math.PI / 2, Math.PI);
+ points.push({ x, y: y + r });
+ arc(x + r, y + r, Math.PI, Math.PI * 1.5);
+ return points;
+}
+
+/** Total length of a closed polyline's perimeter. */
+function ringLength(points) {
+ let total = 0;
+ for (let i = 0; i < points.length; i++) {
+ total += distance(points[i], points[(i + 1) % points.length]);
+ }
+ return total;
+}
+
+/** `count` points spaced evenly by arc length around a closed polyline. */
+function sampleRing(points, count) {
+ const n = points.length;
+ const total = ringLength(points);
+ if (!(total > 0) || !(count > 0)) return [];
+ const out = [];
+ let seg = 0;
+ let start = 0;
+ let length = n > 1 ? distance(points[0], points[1 % n]) : 0;
+ for (let i = 0; i < count; i++) {
+ const target = (total * i) / count;
+ while (seg < n - 1 && start + length < target) {
+ start += length;
+ seg += 1;
+ length = distance(points[seg], points[(seg + 1) % n]);
+ }
+ const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0;
+ out.push(lerp(points[seg], points[(seg + 1) % n], t));
+ }
+ return out;
+}
+
/**
* Replace part of the edge the tail points at with a spike.
*
@@ -923,7 +1127,8 @@ function resolveFontSize(caption, scale) {
function bubbleOf(caption) {
const value = caption?.bubble;
- if (value === 'round' || value === 'rect' || value === 'shout' || value === 'none') return value;
+ if (value === 'round' || value === 'rect' || value === 'shout'
+ || value === 'whisper' || value === 'think' || value === 'none') return value;
return 'round';
}
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 11319c1..716fcab 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -1207,18 +1207,27 @@ function drawGlasses(ctx, eye, line, style, limits) {
// the full height so even that point cannot reach the canvas border.
const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry;
+ // The face's midline: the two eyes are symmetric about it, so it is where the
+ // two halves of the bridge meet and what `tilt` turns the pair about.
+ const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2;
+ // `lensGap` slides the two lenses apart (positive) or together (negative) along
+ // the face, symmetrically about the midline. It is clamped so the inner edges may
+ // meet at the midline but the two lenses can never cross each other.
+ const inwardRoom = Math.max(0, Math.abs(midX - eye.cx) - rx);
+ const gap = clamp(spec.lensGap ?? 0, -inwardRoom, HUGE);
// Keep the lens (and so everything that hangs off it) inside the artwork. The
// rest of the file clamps against `limits` for the same reason: a mark that
// reaches the canvas border gets the edge row copied across the whole plate.
- const lensCx = clamp(eye.cx, limits.left + EDGE_MARGIN + rx, limits.right - EDGE_MARGIN - rx);
+ const lensCx = clamp(
+ eye.cx + out * gap,
+ limits.left + EDGE_MARGIN + rx,
+ limits.right - EDGE_MARGIN - rx,
+ );
const lensCy = clamp(
eye.cy + L.drop + (spec.offsetY ?? 0),
limits.top + EDGE_MARGIN + halfV,
limits.bottom - EDGE_MARGIN - halfV,
);
- // The face's midline: the two eyes are symmetric about it, so it is where the
- // two halves of the bridge meet and what `tilt` turns the pair about.
- const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2;
// Local (u, v) -> canvas, with u outwards and v downwards.
const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v });
@@ -1366,6 +1375,12 @@ export function drawEyes(ctx, p) {
const lookMax = style.lookMax ?? 1;
const heartScale = 2;
const heartColor = style.heartColor ?? '#e0344f';
+ // A lens that is effectively opaque hides the eye completely, so a blink would
+ // show only as a flicker of the shut artwork through the lens. While such a
+ // lens is on, the eyes are drawn open and no blink is visible at all. This is a
+ // rendering decision, not an animation one - the blink still runs underneath.
+ const lensOpaque = style.glasses?.enabled === true
+ && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99;
// ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
// rather than inside the per-eye loop below. 鼻ちょうちん comes along too.
@@ -1388,7 +1403,7 @@ export function drawEyes(ctx, p) {
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.
- const shapeKey = eyeShapeKey(spec);
+ const shapeKey = eyeShapeKey(lensOpaque ? { ...spec, open: 1 } : spec);
const explicitShut = typeof spec.shape === 'string'
&& (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line'));
const isHeart = shapeKey === 'heart';
@@ -1408,7 +1423,9 @@ export function drawEyes(ctx, p) {
// "shut"; lift it or the artwork would be clipped away. An explicit shut
// `shape` keeps its own lid height, so the lid can be lowered onto it.
const rawOpen = spec.open ?? 1;
- const open = clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1);
+ const open = lensOpaque
+ ? 1
+ : clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1);
const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1);
// 大きさ: one knob for the iris, the heart and the shut artwork.
const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2);
@@ -1480,10 +1497,13 @@ export function drawEyes(ctx, p) {
// --- the eye's content, clipped by the lids -------------------------
// Every shape is drawn the same way now: a white backing, then the artwork
// (iris, heart or the shut line/3/arch), trimmed by the upper and lower lids.
- // The shut artwork reaches past the eyeball, so it is only lid-clipped - but
- // once the white is shown it is clipped to the eyeball too, so it stays in.
+ // The shut artwork and a heart both reach past the eyeball, so they are only
+ // lid-clipped - but once the white is shown they are clipped to the eyeball
+ // too, so they stay inside it. Clipping the heart by its *lids* rather than by
+ // the eyeball is also what lets a raised lower lid cover it from below: the
+ // heart replaces the eyeball, but it must still sit behind both lids.
ctx.save();
- if (isShutShape && !showWhite) {
+ if ((isShutShape || isHeart) && !showWhite) {
clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
} else if (!isHeart || open < 0.999 || showWhite) {
clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
diff --git a/public/bluebey-studio/src/hats.js b/public/bluebey-studio/src/hats.js
index 3fb65f3..7a54c46 100644
--- a/public/bluebey-studio/src/hats.js
+++ b/public/bluebey-studio/src/hats.js
@@ -32,6 +32,19 @@ const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaS
);
const box = (w, h, d) => new THREE.BoxGeometry(w, h, d);
+/**
+ * The 線画 fill a part takes, as how far it steps from the paper towards the ink
+ * (see `addMesh` in src/styles.js). Every hat part already gets the light default
+ * tone, but two parts of the *same* hat then come out the same shade - so a part
+ * that sits flush on another (a band on a crown) loses its seam. Tagging it with
+ * a darker tone puts that seam back, without touching リアル / フラット, where the
+ * part's own colour already draws it.
+ */
+function tone(mesh, strength) {
+ mesh.userData.tone = strength;
+ return mesh;
+}
+
/** シルクハット: a tall black crown, a narrow band and a flat brim. */
function buildSilk() {
const group = new THREE.Group();
@@ -39,7 +52,7 @@ function buildSilk() {
const band = material('#3a2f4a');
add(group, cylinder(1.52, 1.52, 0.12, 32), black, 0, 0.06, 0);
add(group, cylinder(1.00, 1.04, 1.62, 32), black, 0, 0.93, 0);
- add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0);
+ tone(add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0), 0.34);
return group;
}
@@ -51,12 +64,12 @@ function buildCap() {
// The crown: a hemisphere whose flat face sits on y = 0.
const crown = add(group, sphere(1.08, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), blue, 0, 0, 0);
crown.scale.set(1, 0.92, 1);
- add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0);
+ tone(add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0), 0.30);
// The bill, tipped down a little at the front.
- const bill = add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92);
+ const bill = tone(add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92), 0.30);
bill.scale.set(1, 1, 1.25);
bill.rotation.x = -0.12;
- add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0);
+ tone(add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0), 0.30);
return group;
}
@@ -67,7 +80,7 @@ function buildChef() {
const shade = material('#e6e3da');
// The band: a tall cylinder that sits on the head.
add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0);
- add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0);
+ tone(add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0), 0.26);
// The crown: a big squashed sphere ringed with lobes, so the top reads as the
// classic pleated toque rather than a plain dome.
const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0);
@@ -90,12 +103,12 @@ function buildFire() {
const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 });
const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0);
dome.scale.set(1, 0.95, 1);
- const brim = add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18);
+ const brim = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28);
brim.scale.set(1, 1, 1.1);
// The ridge along the top, front to back.
- const crest = add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0);
+ const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28);
crest.rotation.x = -0.05;
- add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02);
+ tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22);
return group;
}
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 23ed017..20c6c7d 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -521,9 +521,9 @@ async function init() {
// Hats are built at runtime, so they are not in the GLB's mesh list. Register
// them with the style system (and the current hat) so they follow flat/line art
// and get an outline hull like every other part.
- hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true });
+ hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true });
hatStyleUnregister = (mesh) => styles.removeMesh(mesh);
- for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true });
+ for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true });
const face = new Face({
eyeMesh: model.parts.eyeMesh,
mouthMesh: model.parts.mouthMesh,
@@ -980,9 +980,10 @@ async function init() {
);
propRoot.add(group);
// Register with the style system, so フラット and 線画 reach the props too
- // and each part gets an outline hull like the body and the hats.
+ // and each part gets an outline hull like the body and the hats. `lineOnly`
+ // keeps that outline out of リアル/フラット, where the props read by shading.
group.traverse((object) => {
- if (object.isMesh) styles.addMesh(object);
+ if (object.isMesh) styles.addMesh(object, { lineOnly: true });
});
// 看板 only: paint the saved text onto the freshly built face (no-op for
// every other prop, which has no writing surface).
@@ -1839,6 +1840,8 @@ async function init() {
updateKeyLight();
key.intensity = state.render.lightIntensity;
ambient.intensity = state.render.ambient;
+ key.color.set(state.render.lightColor ?? '#ffffff');
+ ambient.color.set(state.render.ambientColor ?? '#ffffff');
renderer.toneMappingExposure = state.render.exposure;
applyBackground();
renderer.shadowMap.needsUpdate = true;
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index f7053cb..bb409ef 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -4,7 +4,7 @@ import {
} from './ui.js';
import {
FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS,
- defaultState, applyPatch, BEARD_KIND_DEFAULTS,
+ defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS,
} from './presets.js';
import { STYLE_DEFS } from './styles.js';
import { ENVIRONMENTS } from './look.js';
@@ -40,7 +40,7 @@ const SECTION_ICONS = {
'見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01',
'背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
'舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5',
- 'セリフ(吹き出し)': 'M4 5h16v10H9l-4 4v-4H4V5Z',
+ 'セリフ(フキダシ)': 'M4 5h16v10H9l-4 4v-4H4V5Z',
'画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8',
'画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01',
'画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01',
@@ -172,7 +172,38 @@ export function buildPanel(app, root) {
open: +rnd(0.4, 1).toFixed(2),
lookX: +rnd(-0.35, 0.35).toFixed(2),
lookY: +rnd(-0.25, 0.25).toFixed(2),
+ eyeX: Math.round(rnd(-16, 16)),
});
+ const randomGlasses = () => {
+ const kind = pick(['glasses', 'sunglasses']);
+ const dark = kind === 'sunglasses';
+ return {
+ enabled: true,
+ kind,
+ frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']),
+ lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']),
+ lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2),
+ lensGap: Math.round(rnd(-24, 56)),
+ frameWidth: +rnd(0.6, 1.8).toFixed(2),
+ scale: +rnd(0.9, 1.15).toFixed(2),
+ offsetY: Math.round(rnd(-18, 18)),
+ tilt: Math.round(rnd(-12, 12)),
+ };
+ };
+ const randomBeard = (shape) => {
+ const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 };
+ if (shape === 'off') return { shape };
+ return {
+ shape,
+ ...base,
+ size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2),
+ offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)),
+ };
+ };
+ const randomHeadMark = () => {
+ const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']);
+ return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) };
+ };
const randomFacePatch = () => {
const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']);
return {
@@ -180,8 +211,10 @@ export function buildPanel(app, root) {
left: randomEye(),
right: randomEye(),
cheeks: { enabled: Math.random() < 0.5 },
- beard: { shape: beardShape, ...(BEARD_KIND_DEFAULTS[beardShape] ?? { size: 1, offsetY: 0, spacing: 0 }) },
- headMark: { shape: pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']) },
+ // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。
+ glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(),
+ beard: randomBeard(beardShape),
+ headMark: randomHeadMark(),
brow: {
enabled: true,
// Random brows are line drawings, never ゴル風's loaded picture.
@@ -198,8 +231,11 @@ export function buildPanel(app, root) {
// The mouth takes the height and the tilt too, so the face really changes.
mouth: {
smile: +rnd(-1, 1.2).toFixed(2),
+ // ときどき丸く開く口(O)にする。
+ round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0,
open: +rnd(0, 0.5).toFixed(2),
width: +rnd(0.7, 1.3).toFixed(2),
+ thickness: +rnd(0.7, 1.6).toFixed(2),
tilt: Math.round(rnd(-12, 12)),
offsetY: Math.round(rnd(-16, 16)),
},
@@ -532,8 +568,8 @@ export function buildPanel(app, root) {
const EYE_ICONS = {
both: '',
- left: '',
- right: '',
+ left: '',
+ right: '',
lid: '',
};
// Each eye group carries the same controls. Linked, one group ("両目") drives
@@ -809,7 +845,8 @@ export function buildPanel(app, root) {
faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、'
+ 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。'
- + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。'));
+ + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。'
+ + '「左の目」「右の目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。'));
const eyeColors = {
white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }),
@@ -1013,6 +1050,10 @@ export function buildPanel(app, root) {
format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)),
onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); },
}),
+ lensGap: slider({
+ label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); },
+ }),
frameWidth: slider({
label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); },
@@ -1032,6 +1073,7 @@ export function buildPanel(app, root) {
glassesBox.add(glassesSliders.frameWidth.el);
glassesBox.add(glassesSliders.offsetY.el);
glassesBox.add(glassesSliders.tilt.el);
+ glassesBox.add(glassesSliders.lensGap.el);
const glassesFrameColor = colorField({
label: 'フレームの色', value: '#2a1e33',
swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'],
@@ -1127,7 +1169,15 @@ export function buildPanel(app, root) {
{ value: 'curve', label: 'カーブ' },
],
value: 'off',
- onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); sync(); },
+ onChange: (value) => {
+ const mark = state.face.eyes.headMark;
+ mark.shape = value;
+ // Entering a shape applies its own starting size (ぎざぎざ is smaller and
+ // shallower than the shared defaults). Other shapes just keep the values.
+ Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {});
+ app.actions.refresh('face');
+ sync();
+ },
});
faceSection.add(headMarkShape.el);
@@ -1295,14 +1345,14 @@ export function buildPanel(app, root) {
const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile)));
const mouthSliders = {
- smile: slider({ label: '笑いの深さ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }),
- open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }),
+ smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }),
+ open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }),
round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }),
- width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }),
- length: slider({ label: '口の長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }),
- thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }),
- tilt: slider({ label: '口の傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }),
- offsetY: slider({ label: '口の高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }),
+ width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }),
+ length: slider({ label: '長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }),
+ thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }),
+ tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }),
+ offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }),
tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }),
tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }),
corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }),
@@ -1316,7 +1366,7 @@ export function buildPanel(app, root) {
round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'],
};
// DOM order is fixed here; the mode only shows or hides rows.
- const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY'];
+ const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle'];
const mouthMode = segmented({
label: '口の形',
options: [
@@ -1337,7 +1387,7 @@ export function buildPanel(app, root) {
faceSection.add(mouthMode);
for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el);
faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。'
- + 'ふつうの口は笑いの深さと口の開き、丸く開く口(O)はOの大きさで形が決まります。'));
+ + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。'));
faceSection.add(buttons({
items: [{
id: 'mouthreset',
@@ -1361,7 +1411,7 @@ export function buildPanel(app, root) {
/* ---------------------------------------------------------------- caption */
- const captionSection = section(tabScene, 'セリフ(吹き出し)');
+ const captionSection = section(tabScene, 'セリフ(フキダシ)');
// Two bubbles share this one set of controls: the selector says which bubble
// they are editing, and every handler below reads the active one when it runs,
@@ -1585,7 +1635,7 @@ export function buildPanel(app, root) {
};
for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
- + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
+ + '「シーン」タブの「セリフ(フキダシ)」の文とは別で、こちらは長さにだけ使います。'));
// --- 見る先 (moved here, next to the face controls, from the pose tab) ----
const lookAtSection = faceTabSections.look;
@@ -2510,9 +2560,23 @@ export function buildPanel(app, root) {
label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2),
onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); },
});
+ const lightColorField = colorField({
+ label: '光の色', value: '#ffffff',
+ swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'],
+ onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); },
+ });
+ const ambientColorField = colorField({
+ label: '環境光の色', value: '#ffffff',
+ swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'],
+ onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); },
+ });
lightSection.add(lightIntensitySlider.el);
lightSection.add(ambientSlider.el);
+ lightSection.add(lightColorField.el);
+ lightSection.add(ambientColorField.el);
lightSection.add(exposureSlider.el);
+ lightSection.add(hint('「光の色」でライトの色、「環境光の色」で影側の色を変えられます。'
+ + 'テクスチャ(環境)の映りこみは「見た目」タブの「環境の映りこみ」で調整できます。'));
/* ----------------------------------------------------------------- camera */
@@ -2905,6 +2969,8 @@ export function buildPanel(app, root) {
});
lightIntensitySlider.set(state.render.lightIntensity ?? 2.1);
ambientSlider.set(state.render.ambient ?? 0.9);
+ lightColorField.set(state.render.lightColor ?? '#ffffff');
+ ambientColorField.set(state.render.ambientColor ?? '#ffffff');
exposureSlider.set(state.render.exposure ?? 1);
const line = ['lineart', 'outline'].includes(state.render.style);
outlineToggle.el.querySelector('input').disabled = line;
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 6bcf724..64480a9 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -80,6 +80,15 @@ export const BEARD_KIND_DEFAULTS = {
kaiser: { size: 1, offsetY: 80, spacing: -10 },
};
+/**
+ * Per-shape starting values for 髪 (headMark), applied when a shape is picked.
+ * `teeth` only matters for ぎざぎざ, so its default is set here rather than on
+ * the shared `size`/`teeth` fields.
+ */
+export const HEAD_MARK_KIND_DEFAULTS = {
+ fringe: { size: 0.83, teeth: 0.06 },
+};
+
/**
* 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or
* two opposite cuts) can be made. A wall's position is kept *relative to the
@@ -155,6 +164,9 @@ export function defaultState() {
lightElevation: 40,
lightIntensity: 2.1,
ambient: 0.9,
+ // The colour of the main light and of the ambient (sky) light.
+ lightColor: '#ffffff',
+ ambientColor: '#ffffff',
exposure: 1,
// Body colours. A theme fills these in; the colour pickers then adjust
// them one part at a time, so a theme is a starting point, not a mode.
@@ -226,7 +238,7 @@ export function defaultState() {
// 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
// `x`/`z` slide it sideways / forwards, `height` lifts it off the head;
// `tiltX` leans it forward/back and `tiltZ` leans it sideways.
- hat: { kind: 'none', custom: false, x: 0, z: 0, height: 0, tiltX: 0, tiltZ: 0 },
+ hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 },
eyes: {
source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
linked: true, // move both eyes together
@@ -329,6 +341,7 @@ export function defaultState() {
frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke)
lensColor: '#2b2433',
lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark
+ lensGap: 0, // px, + moves the two lenses further apart
scale: 1, // lens size multiplier
offsetY: 0, // px, artwork units (positive = down)
tilt: 0, // degrees, both lenses turn together
@@ -867,6 +880,8 @@ export const BUBBLE_STYLES = [
{ value: 'round', label: 'まる' },
{ value: 'rect', label: 'しかく' },
{ value: 'shout', label: 'さけぶ' },
+ { value: 'whisper', label: 'ささやき' },
+ { value: 'think', label: '思考' },
{ value: 'none', label: 'なし' },
];
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
index 9fb3e49..a513546 100644
--- a/public/bluebey-studio/src/props.js
+++ b/public/bluebey-studio/src/props.js
@@ -99,6 +99,18 @@ function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) {
return mesh;
}
+/**
+ * The 線画 fill a part takes, as how far it steps from the paper towards the ink
+ * (see `addMesh` in src/styles.js). A prop is paper in 線画, so two parts of a prop
+ * that meet flush - the pencil's lead in its wood, a can's label on its body - come
+ * out as one white shape. Tagging one of them with a tone puts the seam back; it
+ * has no effect on リアル / フラット, where the part's own colour draws it.
+ */
+function tone(mesh, strength) {
+ mesh.userData.tone = strength;
+ return mesh;
+}
+
/** A cuboid, sized as [width, height, depth] and centred on `at`. */
function addBox(parent, material, size, at = [0, 0, 0]) {
const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
@@ -235,8 +247,8 @@ const PLANT_BRANCHES = [
*/
const buildPlant = prop((group, material) => {
addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14);
- addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14);
- addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14);
+ tone(addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14), 0.18);
+ tone(addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14), 0.28);
for (const branch of PLANT_BRANCHES) {
addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035);
@@ -264,13 +276,13 @@ const buildBox = prop((group, material, options) => {
back.position.set(0, 1.00, -0.62);
back.rotation.x = flapsDown ? 0 : -1.05;
group.add(back);
- addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]);
+ tone(addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]), 0.20);
const front = new THREE.Group();
front.position.set(0, 1.00, 0.62);
front.rotation.x = flapsDown ? 0 : 1.05;
group.add(front);
- addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]);
+ tone(addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]), 0.20);
if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]);
});
@@ -287,10 +299,10 @@ const buildBox = prop((group, material, options) => {
const buildCan = prop((group, material) => {
addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16);
addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16);
- addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16);
+ tone(addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16), 0.20);
addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16);
// A darker disc just inside the rim so the mouth reads as empty, not capped.
- addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16);
+ tone(addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16), 0.30);
// The one lid, peeled back on a hinge at the back of the mouth so its
// underside shows. The hinge sits *on* the can's back rim and the disc is
@@ -356,13 +368,15 @@ const buildFuton = prop((group, material) => {
/** 鉛筆: an oversized pencil standing on its eraser, point up. */
const buildPencil = prop((group, material) => {
const r = 0.13;
- addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6);
- addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6);
+ // Eraser, ferrule and lead are each a band of the same width against the body,
+ // so without a tone of their own they vanish into it in 線画.
+ tone(addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6), 0.20);
+ tone(addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6), 0.24);
addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6);
addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6);
// The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the
// two look joined, while its point still pokes out past the wood.
- addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6);
+ tone(addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6), 0.45);
});
/** コップ: a mug with a rim, a handle and a little drink inside. */
@@ -396,7 +410,7 @@ const buildEraser = prop((group, material) => {
const depth = 0.5;
const half = 0.15;
addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]);
- addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]);
+ tone(addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]), 0.22);
});
export const PROP_LIBRARY = [
diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js
index 1afe669..e3c8a8e 100644
--- a/public/bluebey-studio/src/rig.js
+++ b/public/bluebey-studio/src/rig.js
@@ -16,6 +16,21 @@ const DEG = 180 / Math.PI;
/** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */
const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase();
+/**
+ * The same angle shifted by whole turns to sit next to `reference`. On an exact
+ * half-turn tie the lower (smaller-magnitude) value wins, which keeps the
+ * canonical -180..180 reading when two are equally close.
+ */
+const nearestAngle = (angle, reference) => {
+ const turns = (reference - angle) / 360;
+ const low = angle + 360 * Math.floor(turns);
+ const high = angle + 360 * Math.ceil(turns);
+ return Math.abs(low - reference) <= Math.abs(high - reference) ? low : high;
+};
+
+/** True when two quaternions describe the same rotation (double cover included). */
+const sameRotation = (a, b) => Math.abs(Math.abs(a.dot(b)) - 1) < 1e-6;
+
export class Rig {
constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) {
this.bones = bones;
@@ -31,6 +46,12 @@ export class Rig {
this.pickTargets = pickTargets;
this.onChange = onChange;
this.selected = null;
+ // The delta last handed to (or written by) the UI, in degrees. An XYZ Euler
+ // has several equivalent triples for one rotation, so getDelta returns the
+ // one closest to this. That keeps a single-axis drag smooth through the
+ // wrap at 90 deg (the middle axis) instead of jumping to the gimbal-flipped
+ // twin, which made the sliders and the next setDelta spin the character.
+ this.lastDelta = new Map();
this.controls = new TransformControls(camera, domElement);
this.controls.setMode('rotate');
@@ -87,8 +108,35 @@ export class Rig {
const entry = this.byName.get(name);
if (!entry) return { x: 0, y: 0, z: 0 };
const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion);
- const euler = new THREE.Euler().setFromQuaternion(delta, 'XYZ');
- return { x: euler.x * DEG, y: euler.y * DEG, z: euler.z * DEG };
+ const base = new THREE.Euler().setFromQuaternion(delta, 'XYZ');
+ const reference = this.lastDelta.get(entry) ?? { x: 0, y: 0, z: 0 };
+
+ // For one rotation an XYZ Euler has a second solution - its "flipped" twin
+ // `(x+180, 180-y, z+180)`. Three always returns the one whose middle angle
+ // stays within +/-90, so past 90 the twin is what continues the drag; offer
+ // both and keep whichever is closest to the last value. Near gimbal lock the
+ // twin no longer reproduces the rotation, so it is dropped by the check.
+ const candidates = [{ x: base.x, y: base.y, z: base.z }];
+ const flipped = new THREE.Euler(base.x + Math.PI, Math.PI - base.y, base.z + Math.PI, 'XYZ');
+ if (sameRotation(delta, new THREE.Quaternion().setFromEuler(flipped))) {
+ candidates.push({ x: flipped.x, y: flipped.y, z: flipped.z });
+ }
+
+ let best = { x: 0, y: 0, z: 0 };
+ let bestDistance = Infinity;
+ for (const candidate of candidates) {
+ const x = nearestAngle(candidate.x * DEG, reference.x);
+ const y = nearestAngle(candidate.y * DEG, reference.y);
+ const z = nearestAngle(candidate.z * DEG, reference.z);
+ const distance = (x - reference.x) ** 2 + (y - reference.y) ** 2 + (z - reference.z) ** 2;
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = { x, y, z };
+ }
+ }
+
+ this.lastDelta.set(entry, best);
+ return { ...best };
}
setDelta(name, { x = 0, y = 0, z = 0 }) {
@@ -98,6 +146,10 @@ export class Rig {
new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'),
);
entry.bone.quaternion.copy(entry.rest).multiply(delta);
+ // Remember exactly what the UI asked for so getDelta can hand it straight
+ // back (this keeps applyPose/getPose an exact round-trip). Keyed by the bone
+ // entry, not the name, so a raw name and its normalised alias agree.
+ this.lastDelta.set(entry, { x, y, z });
}
/** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */
diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js
index 8efdf71..2e4f62d 100644
--- a/public/bluebey-studio/src/styles.js
+++ b/public/bluebey-studio/src/styles.js
@@ -112,9 +112,13 @@ export class Styles {
this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false });
// A hat is not flat: in a line drawing a white hat on a white head merges
// into one shape, so hats get a light tone instead of paper (see addMesh).
- this.toneMaterial = new THREE.MeshBasicMaterial({ color: toneOf(paper), toneMapped: false });
- /** Runtime meshes that want the tone fill rather than paper in 線画. */
- this.toneMeshes = new Set();
+ // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it
+ // steps from the paper towards the ink is cached by strength in
+ // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a
+ // pencil's lead) can ask for a darker fill and its seam then reads.
+ this.toneMaterials = new Map();
+ /** Runtime meshes that want a tone fill rather than paper in 線画. */
+ this.toneMeshes = new Map();
this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true });
this.outlineWidth = outlineWidth;
@@ -159,6 +163,25 @@ export class Styles {
return entry.material;
}
+ /**
+ * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far
+ * it steps from the paper towards the ink. `userData.tone` on the mesh (set by
+ * the builder) carries the amount; the hat default comes from `options.tone`.
+ */
+ toneMaterialFor(strength = DEFAULT_TONE) {
+ const key = String(strength);
+ let material = this.toneMaterials.get(key);
+ if (!material) {
+ material = new THREE.MeshBasicMaterial({
+ color: toneOf(this.paper, strength),
+ toneMapped: false,
+ });
+ material.userData.tone = strength;
+ this.toneMaterials.set(key, material);
+ }
+ return material;
+ }
+
createOutlines() {
const hulls = [];
for (const mesh of this.meshes) {
@@ -193,13 +216,20 @@ export class Styles {
* styles and gets an outline hull like the GLB parts. `removeMesh` undoes it.
*
* `options.tone` gives the mesh the light tone fill in 線画 instead of paper,
- * for parts whose shape would otherwise merge with the body (the hats).
+ * for parts whose shape would otherwise merge with the body (the hats). A
+ * number instead of `true` picks how far to step towards the ink, and the
+ * builder can override it per mesh with `userData.tone` - which is how a part
+ * draws its seam against a same-coloured neighbour.
+ *
+ * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh
+ * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット.
*/
addMesh(mesh, options = {}) {
if (!mesh || this.originals.has(mesh)) return;
this.originals.set(mesh, mesh.material);
this.meshes.push(mesh);
- if (options.tone) this.toneMeshes.add(mesh);
+ const tone = mesh.userData.tone ?? options.tone;
+ if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone));
const kind = this.kindOf(mesh);
if (!OUTLINE_SKIP.has(kind)) {
const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
@@ -214,7 +244,7 @@ export class Styles {
if (overlay) hull.renderOrder = -1;
hull.userData.kind = kind;
hull.userData.source = mesh;
- hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind);
+ hull.userData.lineOnly = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind);
hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
mesh.parent?.add(hull);
this.outlineMeshes.push(hull);
@@ -264,7 +294,7 @@ export class Styles {
// the leaves - is drawn by the screen-space outline, so no part needs a
// material trick of its own any more.
material = value === 'lineart'
- ? (this.toneMeshes.has(mesh) ? this.toneMaterial : this.paperMaterial)
+ ? (this.toneMeshes.get(mesh) ?? this.paperMaterial)
: this.invisibleMaterial;
} else if (value === 'flat') {
material = this.toonFor(original);
@@ -325,7 +355,9 @@ export class Styles {
setPaper(color) {
this.paper = color;
this.paperMaterial.color.set(color);
- this.toneMaterial.color.copy(toneOf(color));
+ for (const material of this.toneMaterials.values()) {
+ material.color.copy(toneOf(color, material.userData.tone));
+ }
}
setOutlineVisible(visible) {
@@ -379,7 +411,8 @@ export class Styles {
for (const entry of this.hullMaterials.values()) entry.material.dispose();
this.hullMaterials.clear();
this.paperMaterial.dispose();
- this.toneMaterial.dispose();
+ for (const material of this.toneMaterials.values()) material.dispose();
+ this.toneMaterials.clear();
this.invisibleMaterial.dispose();
this.gradientMap.dispose();
for (const material of this.toonMaterials.values()) material.dispose();
@@ -387,9 +420,12 @@ export class Styles {
}
}
-/** A step towards the ink, for the light fill a hat gets in 線画. */
-function toneOf(paper) {
- return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), 0.14);
+/** How far the hat default steps from the paper towards the ink in 線画. */
+const DEFAULT_TONE = 0.14;
+
+/** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */
+function toneOf(paper, strength = DEFAULT_TONE) {
+ return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength);
}
/** A 3-step ramp gives crisper cartoon bands than the shader's default. */
--
cgit v1.3.1