aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/panel.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
-rw-r--r--public/bluebey-studio/src/panel.js2508
1 files changed, 2508 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
new file mode 100644
index 0000000..28ea8f9
--- /dev/null
+++ b/public/bluebey-studio/src/panel.js
@@ -0,0 +1,2508 @@
+import {
+ h, section, subhead, hint, slider, check, segmented, buttons,
+ colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast,
+} from './ui.js';
+import {
+ FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS,
+ defaultState, applyPatch,
+} from './presets.js';
+import { STYLE_DEFS } from './styles.js';
+import { EYE_VARIANTS } from './model.js';
+import { ENVIRONMENTS } from './look.js';
+import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js';
+import { PROP_LIBRARY, applyPropFaceScale } from './props.js';
+import {
+ GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH,
+} from './gion.js';
+
+/** Fallbacks for the fields an older settings file may predate. */
+const BODY_COLOR_FALLBACKS = {
+ body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0',
+ leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0',
+};
+const CAPTION_SLIDER_FALLBACKS = {
+ fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4,
+};
+const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' };
+const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 };
+const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 };
+
+/**
+ * The licence reminder the three image-import buttons show before they open the
+ * file picker. Kept in one place so the wording can change without hunting for
+ * each button.
+ */
+const IMAGE_LICENSE_NOTICE = '読み込む画像は、ご自身で権利をお持ちか、利用許諾のあるものに限ります。ライセンスをご確認ください。';
+
+/** True when the user confirmed they have the right to use the image. */
+function confirmImageLicense() {
+ return window.confirm(IMAGE_LICENSE_NOTICE);
+}
+
+/**
+ * The panel had no multi-line field, and the two features that take free prose
+ * (the caption and the spoken text) want the same shape, so it is built once
+ * here rather than growing the widget kit for a single caller.
+ */
+function textArea({ label, value = '', rows = 3, onChange }) {
+ const input = h('textarea', {
+ rows,
+ style: {
+ width: '100%',
+ boxSizing: 'border-box',
+ resize: 'vertical',
+ font: 'inherit',
+ fontFamily: 'ui-monospace, "Hiragino Kaku Gothic ProN", "Noto Sans JP", monospace',
+ fontSize: '11.5px',
+ lineHeight: '1.5',
+ padding: '6px 8px',
+ border: '1px solid #e4dff0',
+ borderRadius: '8px',
+ background: '#fff',
+ color: '#2b2433',
+ },
+ });
+ input.value = value ?? '';
+ input.addEventListener('input', () => onChange?.(input.value));
+ return {
+ el: controlRow(label, input, { wide: true }),
+ // Assigning the same text again would jump the caret to the end, so only
+ // write when the value actually differs.
+ set(v) { const next = v ?? ''; if (input.value !== next) input.value = next; },
+ get: () => input.value,
+ };
+}
+
+/**
+ * The placed prop groups, in state order.
+ *
+ * WHY this climbs the scene: the app owns the prop rebuild (see `applyProps` in
+ * src/main.js) and hands `props.js` only the whole-prop scale, so a sign's
+ * per-item `faceScale` has to be re-applied to the freshly built mesh by the one
+ * place that knows that value - this panel. There is no direct scene reference
+ * here, so this walks up from the model's container and collects the props: they
+ * are the only objects tagged `userData.propId`, and the app adds them in order.
+ */
+function placedPropGroups(app) {
+ const scene = app.container?.parent;
+ if (!scene) return [];
+ const groups = [];
+ scene.traverse((object) => {
+ if (object.userData?.propId) groups.push(object);
+ });
+ return groups;
+}
+
+/**
+ * Builds the whole control panel and wires it to the app state.
+ *
+ * Widgets never call the heavy "apply everything" path while they are being
+ * dragged; they patch one scope and ask the app to refresh just that part.
+ */
+export function buildPanel(app, root) {
+ const state = app.state;
+ const model = app.model;
+ const rig = app.rig;
+
+ const syncers = [];
+ const boneSyncers = [];
+ const sync = () => { for (const fn of syncers) fn(); };
+ const syncBones = () => { for (const fn of boneSyncers) fn(); };
+
+ /** 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 });
+
+ root.replaceChildren();
+
+ // Four tabs instead of one long scroll: the panel used to be a wall of
+ // controls, and everything is easier to find this way. Each tab now carries a
+ // small stroke icon so the row can be scanned at a glance.
+ const tabBar = tabs(root, [
+ {
+ id: 'pose',
+ label: 'ポーズ',
+ // A standing figure: head, body, arms and legs.
+ icon: 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2',
+ },
+ {
+ id: 'face',
+ label: '表情',
+ // A smiley face: an outline, two eyes and a smile.
+ icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
+ },
+ {
+ id: 'view',
+ label: '見た目',
+ // A painter's palette: the outline, the thumb hole and four colour dots.
+ icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01',
+ },
+ {
+ id: 'export',
+ label: '書き出し',
+ // A download arrow dropping into a tray.
+ icon: 'M12 3.8v9.6M8.2 9.8 12 13.6l3.8-3.8M4.5 16.5v1.6a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-1.6',
+ },
+ ]);
+ const tabPose = tabBar.panels.pose;
+ const tabFace = tabBar.panels.face;
+ const tabView = tabBar.panels.view;
+ const tabExport = tabBar.panels.export;
+
+ /* ------------------------------------------------------------------ pose */
+
+ // ポーズ例: ready-made poses, because most people start by picking one.
+ const posePresetSection = section(tabPose, 'ポーズ例');
+ posePresetSection.add(buttons({
+ items: POSE_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => applyPosePreset(preset),
+ })),
+ }));
+
+ // 全身とボーンのスライダー: pick a bone, then turn it on the three axes.
+ const poseSection = section(tabPose, '全身とボーンのスライダー');
+
+ const boneList = h('div', { class: 'bone-list' });
+ const boneButtons = new Map();
+ for (const bone of model.bones) {
+ const button = h('button', { type: 'button', class: 'bone-item' },
+ h('span', { text: bone.label }),
+ h('small', { text: bone.name }));
+ button.addEventListener('click', () => rig.select(bone.name));
+ boneButtons.set(bone.name, button);
+ boneList.append(button);
+ }
+ poseSection.add(controlRow(null, boneList, { wide: true }));
+
+ const axisSliders = {
+ x: slider({ label: 'よこ(X)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('x', v) }),
+ y: slider({ label: 'たて(Y)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('y', v) }),
+ z: slider({ label: 'ねじり(Z)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('z', v) }),
+ };
+ for (const widget of Object.values(axisSliders)) poseSection.add(widget.el);
+
+ function setAxis(axis, value) {
+ const name = rig.selected;
+ if (!name) return;
+ const delta = rig.getDelta(name);
+ delta[axis] = value;
+ rig.setDelta(name, delta);
+ app.actions.capturePose();
+ app.needsRender = true;
+ }
+
+ poseSection.add(hint('モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。スライダーは「休めの姿勢からの差」です。'));
+
+ // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the
+ // main way in, so they stay folded away inside the bone section.
+ const rootSliders = ['よこ移動', 'たて移動', 'おくゆき'].map((label, index) => slider({
+ label, min: -2, max: 2, step: 0.01, value: 0,
+ format: (v) => v.toFixed(2),
+ onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; },
+ }));
+ const poseBox = details(poseSection.body, 'こまかい設定(ぜんたいをずらす・回転ギズモ)');
+ for (const widget of rootSliders) poseBox.add(widget.el);
+
+ const gizmoToggle = check({
+ label: '回転ギズモを表示',
+ // Off by default: the rings belong to the model's surface, so having them
+ // drawn on top of the character is not what you want while composing a
+ // picture. The 'g' key toggles it too.
+ value: false,
+ onChange: (value) => { rig.setGizmoVisible(value); app.needsRender = true; },
+ });
+ poseBox.add(controlRow(null, gizmoToggle.el, { wide: true }));
+
+ // --- 操作 -----------------------------------------------------------------
+ const poseActions = section(tabPose, '操作');
+ poseActions.add(buttons({
+ items: [
+ { id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } },
+ { id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() },
+ { id: 'random', label: '少しランダムに', onClick: () => randomPose() },
+ { id: 'undo', label: '元に戻す', onClick: () => app.actions.undo() },
+ { id: 'redo', label: 'やり直す', onClick: () => app.actions.redo() },
+ ],
+ }));
+ poseActions.add(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。'));
+
+ /** Accepts a preset object (from the buttons) or a preset id (from tools). */
+ function applyPosePreset(presetOrId) {
+ const preset = typeof presetOrId === 'string'
+ ? POSE_PRESETS.find((item) => item.id === presetOrId)
+ : presetOrId;
+ if (!preset) return;
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyPatch(state.pose, preset.pose ?? {});
+ if (!state.pose.root) state.pose.root = [0, 0, 0];
+ replace({}, 'pose');
+ }
+
+ function randomPose() {
+ const names = model.bones.map((bone) => bone.name);
+ for (const name of names) {
+ if (name === 'master') continue;
+ const scale = name.startsWith('arm') ? 22 : 12;
+ rig.setDelta(name, {
+ x: (Math.random() - 0.5) * scale,
+ y: (Math.random() - 0.5) * scale,
+ z: (Math.random() - 0.5) * scale,
+ });
+ }
+ app.actions.capturePose();
+ syncBones();
+ app.needsRender = true;
+ }
+
+ /* ------------------------------------------------------------------ face */
+
+ let faceSection = section(tabFace, 'プリセット');
+
+ faceSection.add(buttons({
+ items: FACE_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => applyFacePreset(preset.id),
+ })),
+ }));
+
+ function applyFacePreset(id) {
+ const preset = FACE_PRESETS.find((item) => item.id === id);
+ if (!preset) return;
+ state.face = defaultState().face;
+ applyPatch(state.face, preset.face);
+ app.actions.refresh('face');
+ sync();
+ }
+
+ // --- eyes
+ faceSection = section(tabFace, '目');
+
+ const eyeWidgets = {};
+ // The link switch sits directly above the *left* eye's open slider, because that
+ // is the slider it drives - it was previously below both eyes, where it looked
+ // like it belonged to whatever came next.
+ const linkedToggle = check({
+ label: '左右を連動させる',
+ value: true,
+ onChange: (value) => {
+ state.face.eyes.linked = value;
+ if (value) {
+ // Catch the two eyes up with each other, so switching the link on does
+ // something visible even if they had drifted apart.
+ 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;
+ // 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;
+ state.face.eyes.right.tearY = state.face.eyes.left.tearY ?? 0;
+ state.face.eyes.right.tearX = -(state.face.eyes.left.tearX ?? 0);
+ state.face.eyes.right.tearTilt = -(state.face.eyes.left.tearTilt ?? 0);
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ for (const key of ['left', 'right']) {
+ const label = key === 'left' ? '左の目' : '右の目';
+ const open = slider({
+ label: `${label}の開き`,
+ min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`),
+ onInput: (value) => {
+ state.face.eyes[key].open = value;
+ if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].open = value;
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ const closed = segmented({
+ label: `${label}の閉じ方`,
+ options: [
+ { value: 'line', label: '線' },
+ { value: 'three', label: '3の目' },
+ { value: 'arch', label: 'わらう' },
+ ],
+ value: 'line',
+ 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;
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ const closedLines = segmented({
+ label: `${label}の線の数`,
+ options: [
+ { value: '1', label: '1本' },
+ { value: '2', label: '2本' },
+ { value: '3', label: '3本' },
+ ],
+ value: '1',
+ hint: '2本以上は端点がつながった形になります',
+ onChange: (value) => {
+ state.face.eyes[key].closedLines = Number(value);
+ state.face.eyes[key].closed = 'line';
+ 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 = 'line';
+ other.closedLines = Number(value);
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ // The direction of a "3" eye. It used to be tied to the nose side, which made
+ // the pair a mirror image; now each eye says which way its 3 faces.
+ const threeFlip = segmented({
+ label: `${label}の3の向き`,
+ options: [
+ { value: 'normal', label: '3' },
+ { value: 'flip', label: '3(逆)' },
+ ],
+ value: 'normal',
+ hint: '「3の目」のときの向きです。左右で別々に選べます',
+ onChange: (value) => {
+ state.face.eyes[key].threeFlip = value === 'flip';
+ state.face.eyes[key].closed = 'three';
+ state.face.eyes[key].open = 0;
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ // Where this eye sits: per eye, so a lopsided face (one eye slid across) is
+ // possible.
+ const eyeX = slider({
+ label: `${label}のよこ位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); },
+ });
+ // Tears are per eye, and so are where they hang and which way they lean.
+ // When the eyes are linked, a tear edit mirrors onto the other eye: the size
+ // and the height are copied, but the sideways position and the tilt flip
+ // sign. Two drops leaning the same way read as a tilted face, not as crying.
+ const otherKey = key === 'left' ? 'right' : 'left';
+ const setTear = (patch) => {
+ Object.assign(state.face.eyes[key], patch);
+ if (state.face.eyes.linked) {
+ const mirrored = { ...patch };
+ if (patch.tearX != null) mirrored.tearX = -patch.tearX;
+ if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt;
+ Object.assign(state.face.eyes[otherKey], mirrored);
+ }
+ app.actions.refresh('face');
+ sync();
+ };
+ const tear = slider({
+ label: `${label}の涙`, min: 0, max: 1.6, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (value) => setTear({ tear: value }),
+ });
+ const tearY = slider({
+ label: `${label}の涙の高さ`, min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => setTear({ tearY: value }),
+ });
+ const tearX = slider({
+ label: `${label}の涙のよこ位置`, min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => setTear({ tearX: value }),
+ });
+ const tearTilt = slider({
+ label: `${label}の涙の傾き`, min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (value) => setTear({ tearTilt: value }),
+ });
+ eyeWidgets[key] = { open, closed, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt };
+ if (key === 'left') faceSection.add(controlRow(null, linkedToggle.el, { wide: true }));
+ faceSection.add(open.el);
+ faceSection.add(closed.el);
+ faceSection.add(closedLines.el);
+ faceSection.add(threeFlip.el);
+ faceSection.add(eyeX.el);
+ faceSection.add(tear.el);
+ faceSection.add(tearY.el);
+ faceSection.add(tearX.el);
+ faceSection.add(tearTilt.el);
+ }
+
+ // One button to put every eye setting back to the artwork's defaults. The
+ // artwork *source* is kept: resetting the numbers should not throw away a
+ // hand-drawn image the user loaded.
+ faceSection.add(buttons({
+ items: [{
+ id: 'eyesReset',
+ label: '目の設定をリセット',
+ onClick: () => {
+ const source = state.face.eyes.source;
+ state.face.eyes = { ...defaultState().face.eyes, source };
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+
+ const irisShapeSegment = segmented({
+ label: '瞳の形',
+ options: [
+ { value: 'circle', label: '丸' },
+ { value: 'heart', label: 'ハート' },
+ ],
+ value: 'circle',
+ onChange: (value) => {
+ state.face.eyes.left.irisShape = value;
+ state.face.eyes.right.irisShape = value;
+ app.actions.refresh('face');
+ },
+ });
+ faceSection.add(irisShapeSegment.el);
+
+ const linkedNote = hint('「左右を連動させる」を入れると、開き・閉じ方・線の数・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
+ faceSection.add(linkedNote);
+
+ 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;
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(controlRow('目線', h('div', { class: 'pad-wrap' },
+ lookPad.el,
+ h('p', { class: 'hint', text: 'ドラッグで両目が動きます。ダブルクリックで正面に戻ります。' }))));
+
+ const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・ハート・涙・まぶた)');
+ const irisScaleSlider = slider({
+ label: '瞳の大きさ', min: 0.4, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.irisScale = value; app.actions.refresh('face'); },
+ });
+ const heartScaleSlider = slider({
+ label: 'ハートの大きさ', min: 0.4, max: 2.6, step: 0.01, value: 2, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.heartScale = value; app.actions.refresh('face'); },
+ });
+ const heartWhiteToggle = check({
+ label: 'ハートのとき白目を消す',
+ value: true,
+ onChange: (value) => { state.face.eyes.heartHideWhite = value; app.actions.refresh('face'); },
+ });
+ const heartColorField = colorField({
+ label: 'ハートの色', value: '#e0344f',
+ swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'],
+ onChange: (v) => { state.face.eyes.heartColor = v; app.actions.refresh('face'); },
+ });
+ const lookMaxSlider = slider({
+ label: '目線の振れ幅', min: 0, max: 1.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.lookMax = value; app.actions.refresh('face'); },
+ });
+ const lidWidthSlider = slider({
+ label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => { state.face.eyes.lidWidth = value; app.actions.refresh('face'); },
+ });
+ const lowerLidSlider = slider({
+ label: '下まぶた', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.lowerLid = value; app.actions.refresh('face'); },
+ });
+ eyeAdvanced.add(irisScaleSlider.el);
+ eyeAdvanced.add(heartScaleSlider.el);
+ eyeAdvanced.add(controlRow(null, heartWhiteToggle.el, { wide: true }));
+ eyeAdvanced.add(heartColorField.el);
+ eyeAdvanced.add(lookMaxSlider.el);
+ eyeAdvanced.add(lidWidthSlider.el);
+ eyeAdvanced.add(lowerLidSlider.el);
+
+ 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'); } }),
+ line: colorField({ label: 'まぶたの線', value: '#55386e', onChange: (v) => { state.face.eyes.line = v; app.actions.refresh('face'); } }),
+ };
+ const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)');
+ for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el);
+
+ const eyeSource = selectField({
+ label: '目のパーツ',
+ // 実際にモデルに入っている手描きパーツだけを一覧に出す(消したものが残ると混乱するため)
+ options: [
+ { value: 'parametric', label: 'パラメータで作る(おすすめ)' },
+ ...EYE_VARIANTS.filter((variant) => model.originals.eyes[variant.key])
+ .map((variant) => ({ value: variant.key, label: `手描き:${variant.label}` })),
+ ],
+ value: 'parametric',
+ onChange: (value) => { state.face.eyes.source = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(eyeSource.el);
+
+ // 手描き画像の読み込み(GIMP などで描いた PNG をそのまま使えます)
+ const rebuildEyeOptions = () => {
+ const select = eyeSource.el.querySelector('select');
+ if (!select) return;
+ const current = select.value;
+ select.replaceChildren();
+ select.append(h('option', { value: 'parametric', text: 'パラメータで作る(おすすめ)' }));
+ for (const variant of EYE_VARIANTS) {
+ if (model.originals.eyes[variant.key]) {
+ select.append(h('option', { value: variant.key, text: `手描き:${variant.label}` }));
+ }
+ }
+ for (const [key, name] of app.face.customNames) {
+ select.append(h('option', { value: key, text: `読み込み:${name}` }));
+ }
+ select.value = current;
+ };
+ rebuildEyeOptions();
+
+ const eyeImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ eyeImagePicker.addEventListener('change', async () => {
+ const file = eyeImagePicker.files?.[0];
+ eyeImagePicker.value = '';
+ if (!file) return;
+ try {
+ await app.face.loadImage('eyes', file);
+ state.face.eyes.source = app.face.eyeSource;
+ rebuildEyeOptions();
+ sync();
+ toast(`目の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ faceSection.add(eyeImagePicker);
+ faceSection.add(buttons({
+ items: [{ id: 'eyeimg', label: '目の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) eyeImagePicker.click(); } }],
+ }));
+
+ // --- eyebrows (the original character has none; this is extra range)
+ faceSection = section(tabFace, '眉');
+ faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。'));
+
+ const browToggle = check({
+ label: '眉を表示',
+ value: false,
+ onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, browToggle.el, { wide: true }));
+
+ const browSliders = {
+ angle: slider({
+ label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); },
+ }),
+ height: slider({
+ label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ 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,
+ format: (v) => (v < 0.02 ? '点' : v.toFixed(2)),
+ onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); },
+ }),
+ thickness: slider({
+ label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); },
+ }),
+ curve: slider({
+ label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); },
+ }),
+ taper: slider({
+ label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))),
+ onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。'
+ + '高さは + で上がり、- にすると目にかくれて見えなくなります。'));
+ faceSection.add(browSliders.angle.el);
+ const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)');
+ browBox.add(browSliders.height.el);
+ browBox.add(browSliders.length.el);
+ browBox.add(browSliders.thickness.el);
+ browBox.add(browSliders.spacing.el);
+ browBox.add(browSliders.curve.el);
+ browBox.add(browSliders.taper.el);
+ const browColor = colorField({
+ label: '眉の色', value: '#55386e',
+ swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'],
+ onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); },
+ });
+ browBox.add(browColor.el);
+ browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、'
+ + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。'
+ + '「鼻側の細さ」を 0 にすると鼻側がとがります。'));
+ browBox.add(buttons({
+ items: [
+ {
+ id: 'golgo',
+ 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
+ // it, with the two brows sitting close together.
+ Object.assign(state.face.eyes.brow, {
+ enabled: true,
+ angle: 36,
+ height: 6,
+ length: 1.1,
+ thickness: 3.5,
+ curve: 0,
+ taper: 0,
+ spacing: -10,
+ color: '#12101a',
+ });
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ {
+ id: 'ryotsu',
+ label: '両風',
+ onClick: () => {
+ // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting
+ // low over the eyes. `spacing` is what closes the gap - at this length
+ // the two inner ends meet over the nose (see the panel's note).
+ Object.assign(state.face.eyes.brow, {
+ enabled: true,
+ angle: 10,
+ height: 4,
+ length: 0.6,
+ thickness: 3.5,
+ curve: 0.1,
+ taper: 1,
+ spacing: -205,
+ color: '#181220',
+ });
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
+ }));
+
+ // --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
+ faceSection = section(tabFace, '眼鏡・サングラス');
+ faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
+ + '線画のときはレンズを塗らず、フレームの線だけになります。'));
+
+ const glassesToggle = check({
+ label: '眼鏡をかける',
+ value: false,
+ onChange: (value) => { state.face.eyes.glasses.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, glassesToggle.el, { wide: true }));
+
+ // 眼鏡とサングラスの違いは、おもにレンズの濃さ。切り替えたらその種類のレンズの
+ // 既定値(色と濃さ)を一緒に持ってくる。フレームや大きさには触らないので、
+ // 作りこんだフレームは種類を変えても失われない。
+ const GLASSES_KINDS = {
+ glasses: { lensColor: '#2b2433', lensOpacity: 0.22 },
+ sunglasses: { lensColor: '#1a1620', lensOpacity: 0.75 },
+ };
+ const setGlassesKind = (kind) => {
+ Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] });
+ };
+
+ const glassesKindSegment = segmented({
+ label: '種類',
+ options: [
+ { value: 'glasses', label: '眼鏡' },
+ { value: 'sunglasses', label: 'サングラス' },
+ ],
+ value: 'glasses',
+ onChange: (value) => {
+ setGlassesKind(value);
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(glassesKindSegment.el);
+
+ const glassesSliders = {
+ scale: slider({
+ label: '大きさ', min: 0.7, max: 1.5, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.glasses.scale = v; app.actions.refresh('face'); },
+ }),
+ lensOpacity: slider({
+ label: 'レンズの濃さ', min: 0, max: 1, step: 0.01, value: 0.22,
+ format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)),
+ onInput: (v) => { state.face.eyes.glasses.lensOpacity = 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'); },
+ }),
+ offsetY: slider({
+ label: '高さ', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.glasses.offsetY = 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.eyes.glasses.tilt = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(glassesSliders.scale.el);
+ faceSection.add(glassesSliders.lensOpacity.el);
+ const glassesBox = details(faceSection.body, 'こまかい設定(フレーム・位置・色)');
+ glassesBox.add(glassesSliders.frameWidth.el);
+ glassesBox.add(glassesSliders.offsetY.el);
+ glassesBox.add(glassesSliders.tilt.el);
+ const glassesFrameColor = colorField({
+ label: 'フレームの色', value: '#2a1e33',
+ swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'],
+ onChange: (v) => { state.face.eyes.glasses.frameColor = v; app.actions.refresh('face'); },
+ });
+ const glassesLensColor = colorField({
+ label: 'レンズの色', value: '#2b2433',
+ swatches: ['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e'],
+ onChange: (v) => { state.face.eyes.glasses.lensColor = v; app.actions.refresh('face'); },
+ });
+ glassesBox.add(glassesFrameColor.el);
+ glassesBox.add(glassesLensColor.el);
+ glassesBox.add(hint('「レンズの濃さ」を 0 にするとレンズは透明になり、フレームだけの眼鏡になります。'
+ + '「高さ」は眼鏡全体を上下に動かします(+で下がります)。'
+ + '「傾き」は左右のレンズが一緒に傾きます。'));
+ glassesBox.add(buttons({
+ items: [
+ {
+ id: 'glasses',
+ label: '眼鏡',
+ onClick: () => {
+ setGlassesKind('glasses');
+ state.face.eyes.glasses.enabled = true;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ {
+ id: 'sunglasses',
+ label: 'サングラス',
+ onClick: () => {
+ setGlassesKind('sunglasses');
+ state.face.eyes.glasses.enabled = true;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
+ }));
+
+ // --- ほっぺ (a manga blush) and 頭の模様 (a mark on the head)
+ faceSection = section(tabFace, 'ほっぺ・頭の模様');
+ faceSection.add(hint('ほっぺ(マンガの赤らみ)と、頭の模様を、目や眉と同じ絵にかけます。'
+ + 'どちらも決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。'
+ + 'はじめは両方とも出ていません。'));
+
+ const cheeksToggle = check({
+ label: 'ほっぺを出す',
+ value: false,
+ onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, cheeksToggle.el, { wide: true }));
+
+ const cheekSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); },
+ }),
+ lines: slider({
+ label: '線の数', min: 0, max: 7, step: 1, value: 4,
+ format: (v) => (v < 0.5 ? 'なし' : `${Math.round(v)}本`),
+ onInput: (v) => { state.face.eyes.cheeks.lines = Math.round(v); app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(cheekSliders.size.el);
+ const cheekBox = details(faceSection.body, 'こまかい設定(位置・線・色)');
+ cheekBox.add(cheekSliders.offsetY.el);
+ cheekBox.add(cheekSliders.spacing.el);
+ cheekBox.add(cheekSliders.lines.el);
+ const cheekColor = colorField({
+ label: 'ほっぺの色', value: '#f6a6b8',
+ swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'],
+ onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); },
+ });
+ cheekBox.add(cheekColor.el);
+ cheekBox.add(hint('「線の数」を 0 にすると、線のないほっぺになります。'
+ + '「よこの間隔」は左右のほっぺのへだたり、「たかさ」は目からの下がり具合です。'));
+
+ const headMarkShape = segmented({
+ label: '頭の模様',
+ options: [
+ { value: 'off', label: 'なし' },
+ { value: 'hook', label: 'かぎ' },
+ { value: 'spiral', label: 'うずまき' },
+ { value: 'strokes', label: '三本線' },
+ { value: 'dots', label: '点' },
+ ],
+ value: 'off',
+ onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(headMarkShape.el);
+
+ const headMarkSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, 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({
+ label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -60, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); },
+ }),
+ lineWidth: slider({
+ label: '太さ', min: 0.4, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.headMark.lineWidth = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(headMarkSliders.size.el);
+ const headMarkBox = details(faceSection.body, 'こまかい設定(位置・太さ・色)');
+ headMarkBox.add(headMarkSliders.offsetX.el);
+ headMarkBox.add(headMarkSliders.offsetY.el);
+ headMarkBox.add(headMarkSliders.lineWidth.el);
+ const headMarkColor = colorField({
+ label: '模様の色', value: '#8a4fe0',
+ swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'],
+ onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); },
+ });
+ headMarkBox.add(headMarkColor.el);
+ headMarkBox.add(hint('「頭の模様」を選ぶと出ます。決まったキャラクターの絵ではなく、'
+ + 'よくあるマンガの記号です。色・大きさ・位置は自由に変えられます。'));
+
+ // --- mouth
+ faceSection = section(tabFace, '口');
+
+ const mouthToggle = check({
+ label: '口を表示',
+ value: true,
+ onChange: (value) => { state.face.mouth.visible = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, mouthToggle.el, { wide: true }));
+
+ // 口角の角度は「笑いの深さ」に比例して動かす(笑いの深さ 1 で -8°、-1 で 70°、
+ // その間は直線)。笑いの深さを動かすと追従し、手で微調整したいときのために
+ // スライダー自体は残してある。
+ 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'); } }),
+ 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'); } }),
+ 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'); } }),
+ tongueOut: slider({ label: '舌を外に出す', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOut = v; app.actions.refresh('face'); } }),
+ tongueOutPos: slider({ label: '外に出す舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.5, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOutPos = 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'); } }),
+ cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }),
+ };
+ const mouthVisible = ['smile', 'round', 'width', 'thickness', 'tilt', 'offsetY'];
+ for (const key of mouthVisible) faceSection.add(mouthSliders[key].el);
+ const mouthBox = details(faceSection.body, 'こまかい設定(開き・舌・口角)');
+ for (const key of ['open', 'tongue', 'tonguePos', 'tongueOut', 'tongueOutPos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el);
+ mouthBox.add(buttons({
+ items: [{
+ id: 'mouthreset',
+ label: '口を初期値に戻す',
+ onClick: () => {
+ state.face.mouth = defaultState().face.mouth;
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+
+ const mouthColors = {
+ color: colorField({ label: '口の線', value: '#ff1a44', onChange: (v) => { state.face.mouth.color = v; app.actions.refresh('face'); } }),
+ innerColor: colorField({ label: '口の中', value: '#4a0f1e', onChange: (v) => { state.face.mouth.innerColor = v; app.actions.refresh('face'); } }),
+ tongueColor: colorField({ label: '舌', value: '#ff2d2d', onChange: (v) => { state.face.mouth.tongueColor = v; app.actions.refresh('face'); } }),
+ cornerColor: colorField({ label: '口角', value: '#725497', onChange: (v) => { state.face.mouth.cornerColor = v; app.actions.refresh('face'); } }),
+ };
+ const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)');
+ for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el);
+
+ const mouthSource = selectField({
+ label: '口のパーツ',
+ options: [
+ { value: 'parametric', label: 'パラメータで作る(おすすめ)' },
+ { value: 'original', label: '手描き(原本)' },
+ ],
+ value: 'parametric',
+ onChange: (value) => { state.face.mouth.source = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(mouthSource.el);
+
+ const mouthImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ mouthImagePicker.addEventListener('change', async () => {
+ const file = mouthImagePicker.files?.[0];
+ mouthImagePicker.value = '';
+ if (!file) return;
+ try {
+ await app.face.loadImage('mouth', file);
+ state.face.mouth.source = 'original';
+ sync();
+ toast(`口の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ faceSection.add(mouthImagePicker);
+ faceSection.add(buttons({
+ items: [{ id: 'mouthimg', label: '口の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) mouthImagePicker.click(); } }],
+ }));
+
+ /* ---------------------------------------------------------------- caption */
+
+ const captionSection = section(tabFace, 'セリフ(吹き出し)');
+
+ // 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,
+ // so switching does not have to rewire anything.
+ let activeCaption = 'caption';
+ const cap = () => state[activeCaption];
+ const captionBubbleSegment = segmented({
+ label: 'どの吹き出し',
+ options: [
+ { value: 'caption', label: '①' },
+ { value: 'caption2', label: '②' },
+ ],
+ value: 'caption',
+ onChange: (value) => { activeCaption = value; sync(); },
+ });
+ captionSection.add(captionBubbleSegment.el);
+ captionSection.add(hint('吹き出しは 2 つまで出せます。①と②を切り替えて、それぞれ別の位置・形・色にできます。'));
+
+ const captionToggle = check({
+ label: 'この吹き出しを出す',
+ value: false,
+ onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); },
+ });
+ captionSection.add(controlRow(null, captionToggle.el, { wide: true }));
+
+ const captionText = textArea({
+ label: 'セリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { cap().text = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(captionText.el);
+
+ captionSection.add(buttons({
+ label: '例',
+ items: CAPTION_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); },
+ })),
+ }));
+
+ const bubbleSegment = segmented({
+ label: '形',
+ options: BUBBLE_STYLES,
+ value: 'round',
+ onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); },
+ });
+ const tailSegment = tailPad({
+ label: 'しっぽ',
+ value: 'left',
+ onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(bubbleSegment.el);
+ captionSection.add(tailSegment.el);
+
+ const captionSliders = {
+ fontSize: slider({
+ label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); },
+ }),
+ lineHeight: slider({
+ label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); },
+ }),
+ padding: slider({
+ label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); },
+ }),
+ radius: slider({
+ label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); },
+ }),
+ borderWidth: slider({
+ label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`,
+ onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); },
+ }),
+ };
+ for (const widget of Object.values(captionSliders)) captionSection.add(widget.el);
+
+ const captionPosition = {
+ x: slider({
+ label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().x = v; app.actions.refresh('caption'); },
+ }),
+ y: slider({
+ label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().y = v; app.actions.refresh('caption'); },
+ }),
+ };
+ captionSection.add(captionPosition.x.el);
+ captionSection.add(captionPosition.y.el);
+
+ const captionMaxWidthSlider = slider({
+ label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); },
+ });
+ captionSection.add(captionMaxWidthSlider.el);
+
+ const alignSegment = segmented({
+ label: 'そろえ',
+ options: [
+ { value: 'left', label: '左' },
+ { value: 'center', label: '中' },
+ { value: 'right', label: '右' },
+ ],
+ value: 'left',
+ onChange: (value) => { cap().align = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(alignSegment.el);
+
+ const boldToggle = check({
+ label: '太字', value: false,
+ onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); },
+ });
+ const systemFontToggle = check({
+ label: '標準フォントを使う', value: false,
+ onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); },
+ });
+ captionSection.add(controlRow(null, boldToggle.el, { wide: true }));
+ captionSection.add(controlRow(null, systemFontToggle.el, { wide: true }));
+
+ const captionColors = {
+ textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }),
+ bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }),
+ borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }),
+ };
+ for (const widget of Object.values(captionColors)) captionSection.add(widget.el);
+ captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。'
+ + 'ビューポートで吹き出しをドラッグしても動かせます。'));
+
+ /* -------------------------------------------------------------- 口パク */
+
+ // 口パク only: the mouth moves as if talking, and no sound is made at all.
+ // (The studio used to read the line with the device's own speech voices, but
+ // those belong to the device, so a recording of them is not ours to hand out.)
+ const flapSection = section(tabFace, '口パク(声は出ません)');
+ const flapText = textArea({
+ label: '口パクするセリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { state.mouthFlap.text = value; },
+ });
+ flapSection.add(flapText.el);
+ flapSection.add(buttons({
+ items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }],
+ }));
+
+ const flapSliders = {
+ rate: slider({
+ label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.mouthFlap.rate = v; },
+ }),
+ mouthGain: slider({
+ label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.mouthFlap.mouthGain = v; },
+ }),
+ };
+ for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
+ flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
+ + '上の「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
+
+ /* ------------------------------------------------------ movement (pose tab) */
+
+ // This is the pose tab's うごき section; the widgets sit here because they are
+ // driven by the animator alongside the pose state.
+ const moveSection = section(tabPose, 'うごき');
+
+ const animWidgets = {
+ blink: check({ label: 'まばたきする', value: true, onChange: (v) => { state.anim.blink = v; app.animator.reset(); } }),
+ lookAround: check({ label: '目線がうごく', value: false, onChange: (v) => { state.anim.lookAround = v; app.animator.reset(); } }),
+ };
+
+ const motionSegment = segmented({
+ label: 'からだのうごき',
+ options: [
+ { value: 'off', label: 'とまる' },
+ { value: 'idle', label: 'ゆれる' },
+ { value: 'walk', label: '歩く' },
+ ],
+ value: 'off',
+ onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; },
+ });
+ moveSection.add(motionSegment.el);
+ for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true }));
+
+ moveSection.add(slider({
+ label: 'まばたきの間隔', min: 1, max: 8, step: 0.1, value: 3.4, format: (v) => `${v.toFixed(1)}秒`,
+ onInput: (v) => { state.anim.blinkInterval = v; },
+ }));
+ moveSection.add(slider({
+ label: '動きの速さ', min: 0.2, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.anim.speed = v; },
+ }));
+
+ const recordButton = buttons({
+ items: [{ id: 'record', label: '録画を開始', primary: true, onClick: () => app.actions.toggleRecording() }],
+ });
+ moveSection.add(recordButton.el);
+ moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。'));
+
+ // --- 見る先 (moved here, next to the face controls, from the pose tab) ----
+ const lookAtSection = section(tabFace, '見る先');
+ // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too.
+ const refreshLookAt = () => {
+ app.actions.refresh('lookAt');
+ app.actions.refresh('face');
+ sync();
+ };
+ const lookAtToggle = check({
+ label: '指定した場所を見る',
+ value: false,
+ onChange: (value) => { state.lookAt.enabled = value; refreshLookAt(); },
+ });
+ lookAtSection.add(controlRow(null, lookAtToggle.el, { wide: true }));
+ const lookAtSliders = {
+ x: slider({
+ label: 'よこ(X)', min: -6, max: 6, step: 0.05, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.x = v; refreshLookAt(); },
+ }),
+ y: slider({
+ label: 'たかさ(Y)', min: 0, max: 5, step: 0.05, value: 2.6, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.y = v; refreshLookAt(); },
+ }),
+ z: slider({
+ label: 'おくゆき(Z)', min: -6, max: 6, step: 0.05, value: 3, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.z = v; refreshLookAt(); },
+ }),
+ amount: slider({
+ label: '強さ', min: 0, max: 1, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.amount = v; refreshLookAt(); },
+ }),
+ };
+ for (const widget of Object.values(lookAtSliders)) lookAtSection.add(widget.el);
+ const turnBodyToggle = check({
+ label: '体も向ける',
+ value: false,
+ onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); },
+ });
+ lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true }));
+ lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。'));
+
+ /* ---------------------------------------------------------------- display */
+
+ const displaySection = section(tabView, 'スタイル');
+
+ const styleSegment = segmented({
+ label: 'スタイル',
+ options: STYLE_DEFS.map((style) => ({ value: style.value, label: style.label })),
+ value: 'real',
+ onChange: (value) => replace({ render: { style: value } }, 'render'),
+ });
+ displaySection.add(styleSegment.el);
+ displaySection.add(hint('線画は輪郭線+白ぬりです(体に色がつきません)。線だけを他の絵に重ねたいときは、「書き出し」タブで「PNGの背景を透明にする」を入れてください。'));
+
+ const outlineToggle = check({
+ label: '輪郭線を出す', value: true,
+ onChange: (value) => { state.render.outline = value; app.actions.refresh('render'); },
+ });
+ displaySection.add(controlRow(null, outlineToggle.el, { wide: true }));
+ const outlineWidthSlider = slider({
+ label: '線の太さ(体・手足)', min: 0, max: 0.08, step: 0.001, value: 0.022, format: (v) => v.toFixed(3),
+ onInput: (v) => { state.render.outlineWidth = v; app.actions.refresh('render'); },
+ });
+ displaySection.add(outlineWidthSlider.el);
+ const outlinePixelsSlider = slider({
+ label: '線の太さ(葉っぱ・鼻)', min: 1, max: 5, step: 0.1, value: 2,
+ format: (v) => `${v.toFixed(1)} px`,
+ onInput: (v) => { state.render.outlinePixels = v; app.actions.refresh('render'); },
+ });
+ displaySection.add(outlinePixelsSlider.el);
+ displaySection.add(colorField({
+ label: '線の色', value: '#2a1e33',
+ swatches: ['#2a1e33', '#111111', '#55386e', '#0f5c8c', '#8c4a0f'],
+ onChange: (v) => { state.render.outlineColor = v; app.actions.refresh('render'); },
+ }));
+ displaySection.add(colorField({
+ label: '紙の色', value: '#ffffff',
+ swatches: ['#ffffff', '#fdf8ee', '#f4f0fb'],
+ onChange: (v) => { state.render.paper = v; app.actions.refresh('render'); },
+ }));
+ const leafBodyLineCheck = check({
+ label: '葉っぱと体の境目に線を出す', value: true,
+ onChange: (v) => { state.render.leafBodyLine = v; app.actions.refresh('render'); },
+ title: '線画では体も葉っぱも紙なので、切ると葉っぱと体の区別がつかなくなります。'
+ + '「葉が体にめり込んで見える」のが気になるときに切ってください',
+ });
+ displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true }));
+ displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。'));
+
+ /* ------------------------------------------------------------- body colour */
+
+ const colourSection = section(tabView, '体の色');
+ const themeSegment = segmented({
+ label: 'テーマ',
+ options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })),
+ value: 'original',
+ onChange: (id) => { app.actions.applyTheme(id); sync(); },
+ });
+ colourSection.add(themeSegment.el);
+ colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。'));
+
+ /** Write one part colour, creating `colors` if an old settings file lacked it. */
+ const setBodyColor = (part, value) => {
+ state.render.colors = state.render.colors ?? {};
+ state.render.colors[part] = value;
+ app.actions.refresh('render');
+ };
+ const bodyColors = {
+ body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }),
+ accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }),
+ nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }),
+ leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }),
+ vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }),
+ feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }),
+ };
+ for (const widget of Object.values(bodyColors)) colourSection.add(widget.el);
+
+ const envIntensitySlider = slider({
+ label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); },
+ });
+ colourSection.add(envIntensitySlider.el);
+ colourSection.add(buttons({
+ items: [{
+ id: 'resetColors',
+ label: 'プリセットの色に戻す',
+ onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); },
+ }],
+ }));
+
+ /* -------------------------------------------------------------------- look */
+
+ const lookSection = section(tabView, '見た目の調整');
+ const mirrorToggle = check({
+ label: '左右反転',
+ value: false,
+ onChange: (value) => { state.render.mirror = value; app.actions.refresh('render'); sync(); },
+ });
+ lookSection.add(controlRow(null, mirrorToggle.el, { wide: true }));
+ lookSection.add(hint('体だけでなく、目や口の位置までふくめて反転します。文字と並べるときの向きを決めるときに使います。'));
+
+ const shadowCheck = check({
+ label: '影を落とす',
+ value: true,
+ onChange: (value) => { state.render.shadow = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, shadowCheck.el, { wide: true }));
+ const shadowOpacitySlider = slider({
+ label: '濃さ', min: 0, max: 0.6, step: 0.01, value: 0.22, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.shadowOpacity = v; app.actions.refresh('render'); },
+ });
+ const shadowSoftnessSlider = slider({
+ label: 'ぼかし', min: 0, max: 3, step: 0.05, value: 1.6, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.shadowSoftness = v; app.actions.refresh('render'); },
+ });
+ lookSection.add(shadowOpacitySlider.el);
+ lookSection.add(shadowSoftnessSlider.el);
+ const contactShadowToggle = check({
+ label: '接地影だけ',
+ value: false,
+ onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true }));
+ lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。「接地影だけ」は足もとの丸い影だけにします。'));
+
+ const environmentSegment = segmented({
+ label: '照明の種類',
+ options: ENVIRONMENTS.map((env) => ({ value: env.value, label: env.label })),
+ value: 'gradient',
+ onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(environmentSegment.el);
+
+ /* -------------------------------------------------------------- background */
+
+ const backdropSection = section(tabView, '背景');
+ // 素材 is one button for two state values (a photo or an effect line). This
+ // remembers which of the two was picked last, so leaving 素材 and coming back
+ // returns to it instead of always jumping to the photos.
+ let lastMaterial = 'preset';
+ const backgroundModeSegment = segmented({
+ label: '背景',
+ options: [
+ { value: 'solid', label: '単色' },
+ { value: 'transparent', label: '透明' },
+ { value: 'material', label: '素材' },
+ { value: 'image', label: '自分の画像' },
+ { value: 'camera', label: 'カメラ=AR' },
+ ],
+ value: 'solid',
+ onChange: (value) => {
+ if (value === 'camera') {
+ // The action owns the stream, so let it decide and re-read the state after.
+ Promise.resolve(app.actions.toggleCamera(true)).then(() => sync());
+ return;
+ }
+ if (state.view.background === 'camera') app.actions.toggleCamera(false);
+ state.view.background = value === 'material' ? lastMaterial : value;
+ app.actions.refresh('view');
+ sync();
+ },
+ });
+ backdropSection.add(backgroundModeSegment.el);
+ // 背景の色 only shows in the 単色 mode; it used to live in the スタイル section.
+ const backgroundColour = colorField({
+ label: '背景の色', value: '#ffffff',
+ swatches: ['#ffffff', '#f4f0fb', '#d9f0ff', '#fff0f4', '#1b1230'],
+ onChange: (v) => { state.view.backgroundColor = v; app.actions.refresh('view'); },
+ });
+ backdropSection.add(backgroundColour.el);
+
+ const photoButtons = buttons({
+ label: '写真',
+ items: BACKGROUND_PRESETS.map((preset) => ({
+ id: preset.name,
+ label: preset.label,
+ onClick: () => {
+ state.view.backgroundPreset = preset.name;
+ lastMaterial = 'preset';
+ state.view.background = 'preset';
+ app.actions.refresh('view');
+ sync();
+ },
+ })),
+ });
+
+ const effectButtons = buttons({
+ label: '効果線(まんが)',
+ items: EFFECT_PRESETS.map((effect) => ({
+ id: `effect-${effect.name}`,
+ label: effect.label,
+ onClick: () => {
+ state.view.backgroundEffect = effect.name;
+ lastMaterial = 'effect';
+ state.view.background = 'effect';
+ app.actions.refresh('view');
+ sync();
+ },
+ })),
+ });
+
+ // The two rows are shown only while 素材 is the active mode (see the syncer
+ // below); the border groups them so they read as one choice, not two.
+ const materialGroup = h('div', {
+ class: 'material-group',
+ style: { display: 'none', paddingLeft: '8px', borderLeft: '2px solid #e4dff0' },
+ }, photoButtons.el, effectButtons.el, hint('効果線はアプリが描いています(画像ファイルなし・ネット不要)。集中線=放射、落ち込み線=上から下、疾走線=左右です。'));
+ backdropSection.add(materialGroup);
+
+ const backgroundImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ backgroundImagePicker.addEventListener('change', async () => {
+ const file = backgroundImagePicker.files?.[0];
+ backgroundImagePicker.value = '';
+ if (!file) return;
+ try {
+ const dataUrl = await app.backdrop.loadImageFile(file);
+ state.view.backgroundImage = dataUrl;
+ state.view.background = 'image';
+ app.actions.refresh('view');
+ sync();
+ toast(`背景の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ backdropSection.add(backgroundImagePicker);
+ backdropSection.add(buttons({
+ items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }],
+ }));
+
+ const backgroundFitSegment = segmented({
+ label: '合わせ方',
+ options: [
+ { value: 'cover', label: '全体' },
+ { value: 'contain', label: '収める' },
+ { value: 'stretch', label: '伸ばす' },
+ { value: 'tile', label: '並べる' },
+ ],
+ value: 'cover',
+ onChange: (value) => { state.view.backgroundFit = value; app.actions.refresh('view'); },
+ });
+ const backgroundBlurSlider = slider({
+ label: 'ぼかし', min: 0, max: 20, step: 0.5, value: 0, format: (v) => `${v.toFixed(1)}px`,
+ onInput: (v) => { state.view.backgroundBlur = v; app.actions.refresh('view'); },
+ });
+ const backgroundDarkenSlider = slider({
+ label: '暗さ', min: 0, max: 0.8, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.backgroundDarken = v; app.actions.refresh('view'); },
+ });
+ const backgroundScaleSlider = slider({
+ label: '大きさ', min: 0.5, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.backgroundScale = v; app.actions.refresh('view'); },
+ });
+ backdropSection.add(backgroundFitSegment.el);
+ backdropSection.add(backgroundBlurSlider.el);
+ backdropSection.add(backgroundDarkenSlider.el);
+ backdropSection.add(backgroundScaleSlider.el);
+
+ const cameraFacingSegment = segmented({
+ label: 'カメラ',
+ options: [
+ { value: 'user', label: '前' },
+ { value: 'environment', label: '後ろ' },
+ ],
+ value: 'environment',
+ onChange: (value) => {
+ state.view.cameraFacing = value;
+ // Only a live stream has to be restarted on the other lens.
+ if (state.view.background === 'camera') {
+ Promise.resolve(app.actions.toggleCamera(true)).then(() => sync());
+ return;
+ }
+ app.actions.refresh('view');
+ },
+ });
+ const cameraMirrorToggle = check({
+ label: '左右反転',
+ value: false,
+ onChange: (value) => { state.view.cameraMirror = value; app.actions.refresh('view'); },
+ });
+ backdropSection.add(cameraFacingSegment.el);
+ backdropSection.add(controlRow(null, cameraMirrorToggle.el, { wide: true }));
+
+ // A gyro reading is a delta from the pose the device held when the switch was
+ // turned on, so the camera does not jump when it starts listening.
+ let gyroBase = null;
+ let gyroOff = null;
+ const gyroToggle = check({
+ label: '端末を傾けて見る',
+ value: false,
+ onChange: (value) => {
+ state.view.gyro = value;
+ if (!value) {
+ gyroBase = null;
+ if (gyroOff) { gyroOff(); gyroOff = null; }
+ return;
+ }
+ gyroBase = { azimuth: Number(state.view.azimuth) || 0, polar: Number(state.view.polar) || 76 };
+ app.backdrop?.requestGyro?.();
+ gyroOff = gyroOff ?? app.backdrop?.onGyro?.((reading) => {
+ if (!gyroBase) return;
+ const yaw = reading?.yaw ?? 0;
+ const pitch = reading?.pitch ?? 0;
+ state.view.azimuth = Math.max(-180, Math.min(180, gyroBase.azimuth - yaw));
+ state.view.polar = Math.max(1, Math.min(179, gyroBase.polar - pitch));
+ app.actions.refresh('view');
+ }) ?? null;
+ },
+ });
+ backdropSection.add(controlRow(null, gyroToggle.el, { wide: true }));
+ backdropSection.add(hint('カメラを使うには https か localhost で開いてください(カメラの使用の許可も必要です)。'));
+ backdropSection.add(buttons({
+ items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }],
+ }));
+
+ /* ------------------------------------------------------------------- props */
+
+ // 舞台: the things you put the character *among* - the props and the wall - in
+ // one place, since they are set up together.
+ const stageSection = section(tabView, '舞台');
+ const propSection = details(stageSection.body, '小物');
+ propSection.add(buttons({
+ label: '追加',
+ items: PROP_LIBRARY.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ onClick: () => { app.actions.addProp(entry.id); sync(); },
+ })),
+ }));
+ propSection.add(hint('小物はキャラクターのまわりに置かれ、背景と同じくそのまま写真に写ります。'
+ + 'ビューポートで小物をドラッグすると移動できます(数値でも動かせます)。'));
+ const propListBox = h('div', { class: 'prop-list' });
+ propSection.add(controlRow(null, propListBox, { wide: true }));
+
+ const propWidgets = [];
+ let propShape = '';
+
+ /**
+ * Rebuild the list only when the *set* of props changes: a rebuild mid-drag
+ * would replace the slider the user is holding. The handlers read the item by
+ * index rather than keeping a reference, so a state swap (undo, load) is safe.
+ */
+ const renderProps = () => {
+ const items = state.props?.items ?? [];
+ const shape = items.map((item) => item.kind).join('|');
+ if (shape === propShape && propWidgets.length === items.length) return;
+ propShape = shape;
+ propWidgets.length = 0;
+ propListBox.replaceChildren();
+ items.forEach((item, index) => {
+ const label = PROP_LIBRARY.find((entry) => entry.id === item.kind)?.label ?? item.kind;
+ const block = h('div', {
+ class: 'prop-item',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' },
+ }, subhead(items.length > 1 ? `${label} ${index + 1}` : label));
+ const patch = (key) => (value) => {
+ const target = state.props?.items?.[index];
+ if (target) target[key] = value;
+ app.actions.refresh('render');
+ sync();
+ };
+ const sliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }),
+ y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }),
+ rotY: slider({ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }),
+ scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }),
+ };
+ // The numbers are only a fallback for the drag, so they stay folded away.
+ const numbers = details(block, '数値で調整');
+ for (const widget of Object.values(sliders)) numbers.add(widget.el);
+ // 看板 only: the writing surface can be enlarged on its own, so the frame
+ // and posts stay put. The app rebuilds the props from the state and carries
+ // only the whole-prop scale, so the props syncer re-applies this to the
+ // fresh mesh (see `applyPropFaceScale`).
+ let faceScale = null;
+ if (item.kind === 'sign') {
+ faceScale = slider({
+ label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1,
+ format: (v) => v.toFixed(2), onInput: patch('faceScale'),
+ });
+ block.insertBefore(faceScale.el, numbers.el);
+ }
+ block.append(buttons({
+ items: [{
+ id: 'remove',
+ label: '削除',
+ onClick: () => {
+ state.props = state.props ?? { items: [] };
+ state.props.items = (state.props.items ?? []).filter((_, i) => i !== index);
+ app.actions.refresh('render');
+ renderProps();
+ sync();
+ },
+ }],
+ }).el);
+ propListBox.append(block);
+ propWidgets.push({ index, sliders, faceScale });
+ });
+ };
+ renderProps();
+
+ /* ------------------------------------------------------------- 擬音 */
+
+ // 擬音: manga onomatopoeia as stickers over the picture. The sheets are dense,
+ // so the user picks the crop with a marquee instead of the app slicing them
+ // (see src/gion.js for why). Only the sheet name and the crop rectangle live in
+ // the state - never the bitmap - so a shared link stays small.
+ const gionSection = details(stageSection.body, '擬音(マンガのオノマトペ)');
+ gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。'
+ + '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。'
+ + '貼ったあとはビューポートでドラッグして動かせます。'));
+
+ const gionSheetSelect = selectField({
+ label: 'シート',
+ options: GION_SHEETS.map((sheet) => ({ value: sheet.name, label: sheet.label })),
+ value: GION_SHEETS[0]?.name ?? '',
+ onChange: () => {},
+ });
+ if (GION_SHEETS.length) {
+ gionSection.add(gionSheetSelect.el);
+ gionSection.add(buttons({
+ items: [{
+ id: 'pick',
+ label: '擬音をえらぶ',
+ primary: true,
+ onClick: () => openGionPicker(gionSheetSelect.get()),
+ }],
+ }));
+ } else {
+ // No sheet ships with the studio (see src/gion.js), so there is nothing to
+ // pick yet - the section stays, but only with a note on how to add one.
+ gionSection.add(hint('擬音の画像がまだありません。assets/manga-gion/ に画像を置き、'
+ + 'src/gion.js の GION_SHEETS に登録すると使えます。'));
+ }
+
+ const gionListBox = h('div', { class: 'gion-list' });
+ gionSection.add(controlRow(null, gionListBox, { wide: true }));
+
+ let gionShape = '';
+ const gionWidgets = [];
+
+ /** Select a stamp: the viewport outlines it and the list highlights it. */
+ const selectGion = (id) => {
+ app.gionSelected = id ?? null;
+ app.actions.refresh('gion');
+ sync();
+ };
+
+ /**
+ * A little canvas preview of a stamp's crop, drawn from the sheet once it has
+ * loaded. The sheet itself is only fetched when a stamp first needs it.
+ */
+ const gionPreviews = new Map();
+ function gionPreview(item) {
+ const canvas = h('canvas', { class: 'gion-thumb', width: 84, height: 56 });
+ const draw = (image) => {
+ if (!image?.naturalWidth) return;
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const scale = Math.min(canvas.width / item.sw, canvas.height / item.sh);
+ const w = item.sw * scale;
+ const h = item.sh * scale;
+ ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh,
+ (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
+ };
+ const known = gionPreviews.get(item.sheet);
+ if (known) {
+ draw(known);
+ } else {
+ const image = new Image();
+ image.onload = () => draw(image);
+ image.src = gionSheetUrl(item.sheet);
+ gionPreviews.set(item.sheet, image);
+ }
+ return canvas;
+ }
+
+ /** Rebuild the list only when the *set* of stamps changes (see `renderProps`). */
+ const renderGion = () => {
+ const items = state.gion?.items ?? [];
+ const shape = items
+ .map((item) => `${item.id}:${item.sheet}:${item.sx},${item.sy},${item.sw},${item.sh}`)
+ .join('|');
+ if (shape === gionShape && gionWidgets.length === items.length) return;
+ gionShape = shape;
+ gionWidgets.length = 0;
+ gionListBox.replaceChildren();
+ if (!items.some((item) => item.id === app.gionSelected)) app.gionSelected = null;
+ if (items.length === 0) {
+ gionListBox.append(h('p', { class: 'hint', text: 'まだ擬音は貼られていません。' }));
+ return;
+ }
+ items.forEach((item, index) => {
+ const label = GION_SHEETS.find((sheet) => sheet.name === item.sheet)?.label ?? item.sheet;
+ const block = h('div', {
+ class: 'gion-item',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' },
+ });
+ const preview = gionPreview(item);
+ preview.title = 'クリックで選択';
+ preview.addEventListener('click', () => selectGion(item.id));
+ const head = h('div', { class: 'gion-item-head' }, preview,
+ subhead(`擬音 ${index + 1}(${label})`));
+ block.append(head);
+
+ const patch = (key) => (value) => {
+ const target = state.gion?.items?.[index];
+ if (target) target[key] = value;
+ app.actions.refresh('gion');
+ sync();
+ };
+ const sliders = {
+ w: slider({
+ label: '大きさ', min: 40, max: 900, step: 1, value: DEFAULT_STAMP_WIDTH,
+ format: (v) => `${Math.round(v)}px`, onInput: patch('w'),
+ }),
+ rot: slider({
+ label: '回転', min: -180, max: 180, step: 1, value: 0,
+ format: (v) => `${Math.round(v)}°`, onInput: patch('rot'),
+ }),
+ };
+ const flip = check({ label: '左右反転', value: false, onChange: patch('flip') });
+ block.append(sliders.w.el, sliders.rot.el, controlRow(null, flip.el, { wide: true }));
+ block.append(buttons({
+ items: [
+ { id: 'select', label: 'この擬音を選ぶ', onClick: () => selectGion(item.id) },
+ {
+ id: 'remove',
+ label: '削除',
+ onClick: () => {
+ state.gion = state.gion ?? { items: [] };
+ state.gion.items = (state.gion.items ?? []).filter((_, i) => i !== index);
+ if (app.gionSelected === item.id) app.gionSelected = null;
+ app.actions.refresh('gion');
+ renderGion();
+ sync();
+ },
+ },
+ ],
+ }).el);
+ gionListBox.append(block);
+ gionWidgets.push({ id: item.id, sliders, flip, block });
+ });
+ };
+ renderGion();
+
+ /**
+ * The picker: a full-screen overlay showing the whole sheet, on which the user
+ * drags a rectangle. "追加" turns that rectangle into a stamp in the middle of
+ * the picture; Esc or キャンセル closes without adding anything.
+ */
+ const openGionPicker = (sheetName) => {
+ const sheet = GION_SHEETS.find((entry) => entry.name === sheetName) ?? GION_SHEETS[0];
+ if (!sheet) { toast('擬音のシートがありません'); return; }
+
+ const image = h('img', { class: 'gion-picker-sheet', alt: sheet.label, src: gionSheetUrl(sheet.name) });
+ const marquee = h('div', { class: 'gion-picker-marquee' });
+ const board = h('div', { class: 'gion-picker-board' }, image, marquee);
+ const addButton = h('button', { type: 'button', class: 'btn primary', text: '追加', disabled: true });
+ const cancelButton = h('button', { type: 'button', class: 'btn', text: 'キャンセル' });
+ const overlay = h('div', { class: 'gion-picker' },
+ h('div', { class: 'gion-picker-bar' },
+ h('div', { class: 'gion-picker-title', text: `「${sheet.label}」から擬音をえらぶ` }),
+ h('div', { class: 'gion-picker-actions' },
+ h('span', { class: 'hint', text: 'ドラッグで四角を描く/Escで閉じる' }),
+ addButton,
+ cancelButton)),
+ board);
+
+ /** Where the sheet is currently displayed, in board pixels. */
+ let sheetRect = null;
+ /** The marquee the user is drawing, or the last one drawn. */
+ let current = null;
+ let start = null;
+
+ const layoutSheet = () => {
+ if (!image.naturalWidth) return;
+ const rect = fitSheet(image, { width: board.clientWidth, height: board.clientHeight }, { padding: 12 });
+ image.style.left = `${rect.x}px`;
+ image.style.top = `${rect.y}px`;
+ image.style.width = `${rect.w}px`;
+ image.style.height = `${rect.h}px`;
+ sheetRect = rect;
+ };
+
+ const updateAdd = () => {
+ addButton.disabled = !(current && current.w >= 6 && current.h >= 6 && sheetRect);
+ };
+
+ const drawMarquee = () => {
+ if (!current) { marquee.style.display = 'none'; return; }
+ marquee.style.display = 'block';
+ marquee.style.left = `${current.x}px`;
+ marquee.style.top = `${current.y}px`;
+ marquee.style.width = `${current.w}px`;
+ marquee.style.height = `${current.h}px`;
+ };
+
+ const pointIn = (event, bounds) => ({
+ x: clamp(event.clientX - bounds.left, 0, bounds.width),
+ y: clamp(event.clientY - bounds.top, 0, bounds.height),
+ });
+
+ board.addEventListener('pointerdown', (event) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
+ start = pointIn(event, board.getBoundingClientRect());
+ current = null;
+ drawMarquee();
+ updateAdd();
+ try { board.setPointerCapture(event.pointerId); } catch { /* a nicety, not a need */ }
+ });
+ board.addEventListener('pointermove', (event) => {
+ if (!start) return;
+ current = normalizeRect(start, pointIn(event, board.getBoundingClientRect()));
+ drawMarquee();
+ updateAdd();
+ });
+ const endMarquee = (event) => {
+ if (!start) return;
+ start = null;
+ try { if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); } catch { /* done anyway */ }
+ updateAdd();
+ };
+ board.addEventListener('pointerup', endMarquee);
+ board.addEventListener('pointercancel', endMarquee);
+ image.addEventListener('load', () => { layoutSheet(); updateAdd(); });
+
+ addButton.addEventListener('click', () => {
+ if (!current || !sheetRect) return;
+ const crop = cropFromMarquee(current, sheetRect, image);
+ app.actions.addGionStamp({ sheet: sheet.name, ...crop });
+ close();
+ });
+ cancelButton.addEventListener('click', () => close());
+ overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
+
+ const onKey = (event) => {
+ if (event.key !== 'Escape') return;
+ event.preventDefault();
+ close();
+ };
+ const onResize = () => layoutSheet();
+
+ let closed = false;
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ document.removeEventListener('keydown', onKey);
+ window.removeEventListener('resize', onResize);
+ overlay.remove();
+ };
+
+ document.addEventListener('keydown', onKey);
+ window.addEventListener('resize', onResize);
+ document.body.append(overlay);
+ // A cached sheet is already loaded, so the `load` event never fires for it.
+ if (image.complete && image.naturalWidth) { layoutSheet(); updateAdd(); }
+ };
+
+ /* ------------------------------------------------------------- 見えない壁 */
+
+ // 見えない壁: place a clip plane and bury the character in it, so only the
+ // part in front of the wall still shows. It is the same cut the camera's own
+ // far plane makes, put where you want it (see src/clip.js).
+ const wallSection = details(stageSection.body, '見えない壁');
+ const wallConfig = () => (state.render.wall = state.render.wall ?? defaultState().render.wall);
+ wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。'
+ + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。'
+ + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。'));
+
+ const wallToggle = check({
+ label: '見えない壁で一部を隠す',
+ value: false,
+ onChange: (value) => { wallConfig().on = value; app.actions.refresh('render'); sync(); },
+ });
+ const wallSliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().x = v; app.actions.refresh('render'); sync(); } }),
+ y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().y = v; app.actions.refresh('render'); sync(); } }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().z = v; app.actions.refresh('render'); sync(); } }),
+ yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { wallConfig().yaw = v; app.actions.refresh('render'); sync(); } }),
+ tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { wallConfig().tilt = v; app.actions.refresh('render'); sync(); } }),
+ size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { wallConfig().size = v; app.actions.refresh('render'); sync(); } }),
+ };
+ wallSection.add(wallToggle.el);
+ for (const widget of Object.values(wallSliders)) wallSection.add(widget.el);
+
+ // The wall writes depth but no colour, so it hides what is behind it without
+ // being visible. The guide is the tinted copy of that same rectangle, shown
+ // only while placing - and it is also the switch that says "I am placing the
+ // wall now", so with it off the wall cannot swallow an orbit drag.
+ const wallGuideToggle = check({
+ label: '板を表示(ビューポートでドラッグして動かせます)',
+ value: false,
+ onChange: (value) => { wallConfig().guide = value; app.actions.refresh('render'); sync(); },
+ });
+ wallSection.add(controlRow(null, wallGuideToggle.el, { wide: true }));
+ wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**'
+ + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。'
+ + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。'));
+ wallSection.add(buttons({
+ items: [{
+ id: 'wallReset',
+ label: '見えない壁をリセット',
+ onClick: () => {
+ state.render.wall = defaultState().render.wall;
+ app.actions.refresh('render');
+ sync();
+ },
+ }],
+ }));
+
+ /* ------------------------------------------------------------------ light */
+
+ // 画面・光: the light rig and the camera, which together decide how the picture
+ // is lit and framed.
+ const screenLightSection = section(tabView, '画面・光');
+ const lightSection = details(screenLightSection.body, 'ひかり');
+ const lightPad = xyPad({
+ value: { x: -0.2, y: 0.45 },
+ onChange: ({ x, y }) => {
+ state.render.lightAzimuth = x * 180;
+ state.render.lightElevation = 5 + Math.max(0, y) * 80;
+ app.actions.refresh('render');
+ },
+ });
+ lightSection.add(controlRow('光の向き', h('div', { class: 'pad-wrap' },
+ lightPad.el,
+ h('p', { class: 'hint', text: 'ドラッグで光源の向きを変えられます。' }))));
+ const lightIntensitySlider = slider({
+ label: '明るさ', min: 0, max: 5, step: 0.05, value: 2.1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.lightIntensity = v; app.actions.refresh('render'); },
+ });
+ const ambientSlider = slider({
+ label: '環境光', min: 0, max: 3, step: 0.05, value: 0.9, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.ambient = v; app.actions.refresh('render'); },
+ });
+ const exposureSlider = slider({
+ 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'); },
+ });
+ lightSection.add(lightIntensitySlider.el);
+ lightSection.add(ambientSlider.el);
+ lightSection.add(exposureSlider.el);
+
+ /* ----------------------------------------------------------------- camera */
+
+ const cameraSection = details(screenLightSection.body, 'カメラ');
+
+ const projectionSegment = segmented({
+ label: '映し方',
+ options: [
+ { value: 'persp', label: '遠近あり' },
+ { value: 'ortho', label: '正投影' },
+ ],
+ value: 'persp',
+ onChange: (value) => replace({ view: { projection: value } }, 'view'),
+ });
+ cameraSection.add(projectionSegment.el);
+ cameraSection.add(hint('正投影にすると遠近のゆがみが消え、図面のような絵になります。'));
+
+ cameraSection.add(buttons({
+ label: '向き',
+ items: [
+ { id: 'front', label: '正面', onClick: () => app.actions.setCameraPreset('front') },
+ { id: 'threeQuarter', label: '斜め', onClick: () => app.actions.setCameraPreset('threeQuarter') },
+ { id: 'side', label: '横', onClick: () => app.actions.setCameraPreset('side') },
+ { id: 'back', label: '後ろ', onClick: () => app.actions.setCameraPreset('back') },
+ { id: 'top', label: '見下ろし', onClick: () => app.actions.setCameraPreset('top') },
+ ],
+ }));
+
+ const cameraSliders = {
+ azimuth: slider({
+ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.view.azimuth = v; app.actions.refresh('view'); },
+ }),
+ polar: slider({
+ label: '高さ', min: 1, max: 179, step: 1, value: 76, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.view.polar = v; app.actions.refresh('view'); },
+ }),
+ size: slider({
+ label: '大きさ', min: 2, max: 200, step: 0.1, value: 12, format: (v) => v.toFixed(1),
+ onInput: (v) => {
+ if (state.view.projection === 'ortho') state.view.orthoHeight = v;
+ else state.view.distance = v;
+ app.actions.refresh('view');
+ },
+ }),
+ targetY: slider({
+ label: '見る高さ', min: 0, max: Math.max(2, model.size.y * 1.2), step: 0.05, value: model.size.y * 0.5, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.targetY = v; app.actions.refresh('view'); },
+ }),
+ };
+ for (const widget of Object.values(cameraSliders)) cameraSection.add(widget.el);
+
+ const autoRotateToggle = check({
+ label: '自動で回す', value: false,
+ onChange: (v) => { state.view.autoRotate = v; app.actions.refresh('view'); },
+ });
+ cameraSection.add(controlRow(null, autoRotateToggle.el, { wide: true }));
+ cameraSection.add(slider({
+ label: '回す速さ', min: 0.2, max: 4, step: 0.1, value: 0.8, format: (v) => v.toFixed(1),
+ onInput: (v) => { state.view.autoRotateSpeed = v; app.actions.refresh('view'); },
+ }));
+
+ /* ------------------------------------------------------------------ export */
+
+ const exportSection = section(tabExport, '画像');
+
+ exportSection.add(segmented({
+ label: 'PNGの大きさ',
+ options: [
+ { value: '1', label: '1倍' },
+ { value: '2', label: '2倍' },
+ { value: '3', label: '3倍' },
+ { value: '4', label: '4倍' },
+ ],
+ value: '2',
+ onChange: (value) => { state.render.pngScale = Number(value); },
+ }));
+ exportSection.add(buttons({
+ items: [
+ { id: 'png', label: 'PNGを保存', primary: true, onClick: () => app.actions.savePNG() },
+ { id: 'copy', label: 'クリップボードにコピー', onClick: () => app.actions.copyPNG() },
+ ],
+ }));
+ const transparentCheck = check({
+ label: 'PNGの背景を透明にする', value: false,
+ onChange: (v) => { state.render.pngTransparent = v; },
+ });
+ exportSection.add(transparentCheck.el);
+ exportSection.add(hint('線画のときに透明にすると、体の白ぬりも透明になって線だけが残ります(他の絵に重ねられます)。ふつう・フラットでは体の色はそのまま残ります。'));
+
+ // まんが(コマ)gets its own section: it is a different kind of output.
+ const comicSection = section(tabExport, 'まんが');
+ const storyScaleSegment = segmented({
+ label: '書き出しの大きさ',
+ options: [
+ { value: '1', label: '1×(軽い)' },
+ { value: '1.5', label: '1.5×' },
+ { value: '2', label: '2×(きれい)' },
+ ],
+ value: '1.5',
+ hint: 'コマ1枚の画素数は「いまの画面の大きさ × この倍率」です。'
+ + '2× だと台紙のPNGがとても大きくなります(重いときは 1× か 1.5×)',
+ onChange: (value) => { state.story.scale = Number(value); },
+ });
+ comicSection.add(storyScaleSegment.el);
+ const storyColumnsSlider = slider({
+ label: '列数', min: 1, max: 4, step: 1, value: 1, format: (v) => `${Math.round(v)}列`,
+ onInput: (v) => { state.story.columns = Math.round(v); },
+ });
+ const storyGapSlider = slider({
+ label: '間隔', min: 0, max: 40, step: 1, value: 12, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { state.story.gap = v; },
+ });
+ const storyBackgroundField = colorField({
+ label: '台紙の色', value: '#ffffff',
+ onChange: (v) => { state.story.sheetBackground = v; },
+ });
+ comicSection.add(storyColumnsSlider.el);
+ comicSection.add(storyGapSlider.el);
+ comicSection.add(storyBackgroundField.el);
+ comicSection.add(buttons({
+ items: [
+ { id: 'addPanel', label: '今の状態をコマに追加', onClick: () => { app.actions.addStoryPanel(); sync(); } },
+ { id: 'saveStory', label: 'まんがを書き出す', primary: true, onClick: () => app.actions.saveStory({ scale: state.story?.scale ?? 1.5 }) },
+ ],
+ }));
+ comicSection.add(hint('2列以上で3コマ以上のときは、読む順にコーナーへ番号を付けます。'));
+ const storyPanelBox = h('div', { class: 'story-panels', style: { display: 'grid', gap: '6px' } });
+ comicSection.add(controlRow(null, storyPanelBox, { wide: true }));
+ let storyShape = '';
+
+ /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */
+ const recallStoryPanel = (index) => {
+ const panel = state.story?.panels?.[index];
+ if (!panel) return;
+ // Replace the bones outright: a merged pose would keep bones the panel never had.
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ app.actions.applyState({
+ pose: panel.pose ?? {},
+ face: panel.face ?? {},
+ caption: panel.caption ?? {},
+ caption2: panel.caption2 ?? {},
+ // The 擬音 on the shot. A panel saved before this existed clears them, so
+ // recalling it shows what it actually recorded rather than a leftover.
+ gion: panel.gion ?? { items: [] },
+ // The camera it was recorded with, so the view comes back too. A panel
+ // saved before this existed simply keeps the current camera.
+ ...(panel.view ? { view: panel.view } : {}),
+ }, { scope: 'all', sync: true });
+ };
+
+ const removeStoryPanel = (index) => {
+ state.story = state.story ?? {};
+ state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index);
+ // The empty patch is only here to route the edit through the app's own apply
+ // path: that is what records an undo step and re-reads the panel.
+ app.actions.applyState({}, { scope: 'caption', sync: true });
+ };
+
+ /**
+ * Same rebuild policy as the prop list: only when the set of panels changes.
+ *
+ * A preview arrives a moment *after* its panel does (it needs a render), so its
+ * version is part of the identity: when one turns up, the row is drawn again
+ * with it.
+ */
+ const renderStoryPanels = () => {
+ const panels = state.story?.panels ?? [];
+ const thumbs = app.actions.storyThumbs?.() ?? null;
+ const shape = `${panels.length}:${panels
+ .map((panel) => `${panel.id ?? ''}:${thumbs?.get?.(panel.id)?.version ?? 0}`)
+ .join('|')}`;
+ if (shape === storyShape) return;
+ storyShape = shape;
+ storyPanelBox.replaceChildren();
+ panels.forEach((panel, index) => {
+ const thumb = thumbs?.get?.(panel.id)?.url;
+ const preview = thumb
+ ? h('img', {
+ class: 'story-thumb',
+ alt: panel.label ?? `コマ${index + 1}`,
+ src: thumb,
+ title: 'クリックでこのコマを呼び出す',
+ style: {
+ width: '100%', height: '76px', objectFit: 'contain', cursor: 'pointer',
+ background: '#f3f0fa', border: '1px solid #e4dff0', borderRadius: '8px',
+ },
+ onClick: () => recallStoryPanel(index),
+ })
+ : h('div', {
+ text: '(プレビューなし)',
+ style: {
+ padding: '6px 8px', borderRadius: '8px', background: '#f7f5fd',
+ color: '#8a80a0', fontSize: '11px',
+ },
+ });
+ const block = h('div', {
+ class: 'story-panel',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '6px', display: 'grid', gap: '4px' },
+ }, subhead(panel.label ?? `コマ${index + 1}`), preview);
+ block.append(buttons({
+ items: [
+ { id: 'recall', label: 'このコマを呼び出す', onClick: () => recallStoryPanel(index) },
+ { id: 'remove', label: '削除', onClick: () => removeStoryPanel(index) },
+ ],
+ }).el);
+ storyPanelBox.append(block);
+ });
+ };
+ renderStoryPanels();
+
+ // The share section comes after まんが, so the reading order is 画像 → まんが →
+ // 共有 (the order the outputs are usually wanted in).
+ /* ------------------------------------------------------------ extra export */
+
+ const extraExportSection = section(tabExport, '共有');
+ extraExportSection.add(buttons({
+ items: [
+ { id: 'share-image', label: '画像をシェア', primary: true, onClick: () => app.actions.shareImage() },
+ { id: 'share-x', label: 'Xで投稿', onClick: () => app.actions.shareImage('x') },
+ { id: 'share-fb', label: 'Facebookでシェア', onClick: () => app.actions.shareImage('facebook') },
+ { id: 'share-link', label: 'この見た目のリンクをコピー', onClick: () => app.actions.copyShareLink() },
+ ],
+ }));
+ extraExportSection.add(hint('いまの画面を画像にして「#ぶるべースタジオ」を付けて投稿します。'
+ + 'スマートフォンでは端末の共有画面が開きます。パソコンでは画像をコピーして投稿画面を開くので、貼り付けて投稿してください(Ctrl+V)。'));
+
+ // 手描きの顔パーツを描くための下地。画面を切り取る他の書き出しと違って、これは
+ // 「スタジオが読み込む大きさ・位置そのまま」の素材なので、別のセクションにして
+ // 共有のあとに置く(素材を作る → 見せる、の順)。
+ const faceMapSection = section(tabExport, 'テクスチャの下地を書き出す');
+ faceMapSection.add(buttons({
+ items: [
+ { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') },
+ { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') },
+ ],
+ }));
+ faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。'
+ + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や'
+ + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。'
+ + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'));
+
+ /* --------------------------------------------------------------- settings */
+
+ const settingsSection = section(tabExport, '設定');
+
+ settingsSection.add(buttons({
+ items: [
+ { id: 'save', label: '設定を保存(JSON)', onClick: () => app.actions.saveSettings() },
+ { id: 'load', label: '設定を読み込む', onClick: () => settingsPicker.click() },
+ ],
+ }));
+
+ const settingsPicker = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } });
+ settingsPicker.addEventListener('change', async () => {
+ const file = settingsPicker.files?.[0];
+ settingsPicker.value = '';
+ if (!file) return;
+ try {
+ const text = await file.text();
+ app.actions.loadSettings(text);
+ } catch (error) {
+ console.error(error);
+ toast('設定ファイルを読み込めませんでした');
+ }
+ });
+ settingsSection.add(settingsPicker);
+
+ /* -------------------------------------------------------------------- sync */
+
+ // A tiny read-out of what the renderer is actually doing: if the picture ever
+ // looks wrong or never appears, this says why (buffer size, quality, fps).
+ const infoSection = section(tabView, '画面の情報');
+ const statsLine = h('p', { class: 'hint', text: '計測中…' });
+ infoSection.add(statsLine);
+ infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。'));
+
+ boneSyncers.push(() => {
+ const name = rig.selected;
+ for (const [key, button] of boneButtons) button.classList.toggle('active', key === name);
+ const delta = name ? rig.getDelta(name) : { x: 0, y: 0, z: 0 };
+ axisSliders.x.set(delta.x);
+ axisSliders.y.set(delta.y);
+ axisSliders.z.set(delta.z);
+ const root = state.pose.root ?? [0, 0, 0];
+ rootSliders.forEach((widget, index) => widget.set(root[index] ?? 0));
+ });
+
+ syncers.push(...boneSyncers);
+
+ 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);
+ }
+ irisShapeSegment.set(state.face.eyes.left.irisShape ?? 'circle');
+
+ lookPad.set({ x: state.face.eyes.left.lookX, y: state.face.eyes.left.lookY });
+ highlightToggle.set(state.face.eyes.highlight);
+ irisScaleSlider.set(state.face.eyes.irisScale);
+ heartScaleSlider.set(state.face.eyes.heartScale ?? 0.85);
+ heartWhiteToggle.set(state.face.eyes.heartHideWhite === true);
+ heartColorField.set(state.face.eyes.heartColor ?? '#e0344f');
+ lookMaxSlider.set(state.face.eyes.lookMax ?? 1);
+ lidWidthSlider.set(state.face.eyes.lidWidth ?? 14);
+ lowerLidSlider.set(state.face.eyes.lowerLid ?? 0);
+ browToggle.set(state.face.eyes.brow.enabled);
+ browSliders.angle.set(state.face.eyes.brow.angle);
+ browSliders.height.set(state.face.eyes.brow.height);
+ browSliders.length.set(state.face.eyes.brow.length);
+ browSliders.thickness.set(state.face.eyes.brow.thickness);
+ 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);
+ browColor.set(state.face.eyes.brow.color);
+ glassesToggle.set(state.face.eyes.glasses.enabled === true);
+ glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses');
+ for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]);
+ glassesFrameColor.set(state.face.eyes.glasses.frameColor);
+ glassesLensColor.set(state.face.eyes.glasses.lensColor);
+ eyeColors.white.set(state.face.eyes.white);
+ eyeColors.iris.set(state.face.eyes.iris);
+ eyeColors.line.set(state.face.eyes.line);
+ eyeSource.set(state.face.eyes.source);
+ });
+
+ syncers.push(() => {
+ cheeksToggle.set(state.face.eyes.cheeks.enabled === true);
+ for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]);
+ cheekColor.set(state.face.eyes.cheeks.color);
+ headMarkShape.set(state.face.eyes.headMark.shape ?? 'off');
+ for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]);
+ headMarkColor.set(state.face.eyes.headMark.color);
+ });
+
+ syncers.push(() => {
+ mouthToggle.set(state.face.mouth.visible);
+ for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]);
+ for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]);
+ mouthSource.set(state.face.mouth.source);
+ });
+
+ syncers.push(() => {
+ motionSegment.set(state.anim.mode ?? 'off');
+ animWidgets.blink.set(state.anim.blink);
+ animWidgets.lookAround.set(state.anim.lookAround);
+ });
+
+ syncers.push(() => {
+ styleSegment.set(state.render.style);
+ outlineToggle.set(state.render.outline);
+ outlinePixelsSlider.set(state.render.outlinePixels ?? 2);
+ leafBodyLineCheck.set(state.render.leafBodyLine !== false);
+ transparentCheck.set(state.render.pngTransparent);
+ lightPad.set({
+ x: state.render.lightAzimuth / 180,
+ y: Math.max(0, (state.render.lightElevation - 5) / 80),
+ });
+ lightIntensitySlider.set(state.render.lightIntensity ?? 2.1);
+ ambientSlider.set(state.render.ambient ?? 0.9);
+ exposureSlider.set(state.render.exposure ?? 1);
+ const line = ['lineart', 'outline'].includes(state.render.style);
+ outlineToggle.el.querySelector('input').disabled = line;
+ });
+
+ syncers.push(() => {
+ projectionSegment.set(state.view.projection);
+ cameraSliders.azimuth.set(state.view.azimuth);
+ cameraSliders.polar.set(state.view.polar);
+ const ortho = state.view.projection === 'ortho';
+ cameraSliders.size.set(ortho ? (state.view.orthoHeight || 7.5) : (state.view.distance || 12));
+ cameraSliders.targetY.set(state.view.targetY || model.size.y * 0.5);
+ autoRotateToggle.set(state.view.autoRotate);
+ });
+
+ syncers.push(() => {
+ lookAtToggle.set(state.lookAt?.enabled === true);
+ for (const [key, widget] of Object.entries(lookAtSliders)) {
+ widget.set(state.lookAt?.[key] ?? LOOK_AT_FALLBACKS[key]);
+ }
+ turnBodyToggle.set(state.lookAt?.turnBody === true);
+ });
+
+ syncers.push(() => {
+ themeSegment.set(state.render?.theme ?? 'original');
+ for (const [key, widget] of Object.entries(bodyColors)) {
+ widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]);
+ }
+ envIntensitySlider.set(state.render?.envIntensity ?? 1);
+ });
+
+ syncers.push(() => {
+ mirrorToggle.set(state.render?.mirror === true);
+ shadowCheck.set(state.render?.shadow !== false);
+ shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22);
+ shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6);
+ contactShadowToggle.set(state.render?.contactShadow === true);
+ environmentSegment.set(state.render?.environment ?? 'gradient');
+ });
+
+ syncers.push(() => {
+ const background = state.view?.background ?? 'solid';
+ // 素材 is active for either half of it, and its rows show only then.
+ const isMaterial = background === 'preset' || background === 'effect';
+ backgroundModeSegment.set(isMaterial ? 'material' : background);
+ materialGroup.style.display = isMaterial ? '' : 'none';
+ backgroundColour.set(state.view?.backgroundColor ?? '#ffffff');
+ backgroundFitSegment.set(state.view?.backgroundFit ?? 'cover');
+ backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0);
+ backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0);
+ backgroundScaleSlider.set(state.view?.backgroundScale ?? 1);
+ cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment');
+ cameraMirrorToggle.set(state.view?.cameraMirror === true);
+ gyroToggle.set(state.view?.gyro === true);
+ });
+
+ syncers.push(() => {
+ renderProps();
+ const items = state.props?.items ?? [];
+ // A rebuild only carries the whole-prop scale, so re-apply each sign's face
+ // size to the freshly built mesh here (see `placedPropGroups`).
+ const groups = placedPropGroups(app);
+ for (const entry of propWidgets) {
+ const item = items[entry.index] ?? {};
+ entry.sliders.x.set(item.x ?? 0);
+ entry.sliders.y.set(item.y ?? 0);
+ entry.sliders.z.set(item.z ?? 0);
+ entry.sliders.rotY.set(item.rotY ?? 0);
+ entry.sliders.scale.set(item.scale ?? 1);
+ entry.faceScale?.set(item.faceScale ?? 1);
+ applyPropFaceScale(groups[entry.index], item.faceScale);
+ }
+ });
+
+ syncers.push(() => {
+ renderGion();
+ const items = state.gion?.items ?? [];
+ for (const entry of gionWidgets) {
+ const item = items.find((candidate) => candidate.id === entry.id);
+ if (!item) continue;
+ entry.sliders.w.set(item.w ?? DEFAULT_STAMP_WIDTH);
+ entry.sliders.rot.set(item.rot ?? 0);
+ entry.flip.set(item.flip === true);
+ entry.block.classList.toggle('active', item.id === app.gionSelected);
+ }
+ });
+
+ syncers.push(() => {
+ const wall = state.render?.wall ?? defaultState().render.wall;
+ wallToggle.set(wall.on === true);
+ for (const [key, widget] of Object.entries(wallSliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0));
+ wallGuideToggle.set(wall.guide === true);
+ });
+
+ syncers.push(() => {
+ captionBubbleSegment.set(activeCaption);
+ const bubble = cap() ?? {};
+ captionToggle.set(bubble.enabled === true);
+ captionText.set(bubble.text ?? '');
+ bubbleSegment.set(bubble.bubble ?? 'round');
+ tailSegment.set(bubble.tail ?? 'left');
+ for (const [key, widget] of Object.entries(captionSliders)) {
+ widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]);
+ }
+ captionPosition.x.set(bubble.x ?? 0.58);
+ captionPosition.y.set(bubble.y ?? 0.16);
+ captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36);
+ alignSegment.set(bubble.align ?? 'left');
+ boldToggle.set(bubble.bold === true);
+ systemFontToggle.set(bubble.font === 'system');
+ for (const [key, widget] of Object.entries(captionColors)) {
+ widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]);
+ }
+ });
+
+ syncers.push(() => {
+ flapText.set(state.mouthFlap?.text ?? '');
+ for (const [key, widget] of Object.entries(flapSliders)) {
+ widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]);
+ }
+ });
+
+ syncers.push(() => {
+ storyScaleSegment.set(String(state.story?.scale ?? 1.5));
+ storyColumnsSlider.set(state.story?.columns ?? 1);
+ storyGapSlider.set(state.story?.gap ?? 12);
+ storyBackgroundField.set(state.story?.sheetBackground ?? '#ffffff');
+ renderStoryPanels();
+ });
+
+ app.actions.applyFacePreset = applyFacePreset;
+ app.actions.applyPosePreset = applyPosePreset;
+
+ sync();
+
+ // The 'g' key toggles the gizmo behind the panel's back, so the checkbox
+ // follows the rig rather than the other way round.
+ syncers.push(() => gizmoToggle.set(rig.helper.visible === true));
+
+ return {
+ sync,
+ onRigChanged: syncBones,
+ /** Which bubble the caption controls are editing (a drag on it calls this). */
+ selectCaption(key) {
+ if (key !== 'caption' && key !== 'caption2') return;
+ activeCaption = key;
+ sync();
+ },
+ /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */
+ selectGion(id) {
+ app.gionSelected = id ?? null;
+ sync();
+ },
+ /** Open the crop picker for a sheet (the 擬音 section's button calls this). */
+ openGionPicker(sheet) {
+ openGionPicker(sheet ?? gionSheetSelect.get());
+ },
+ setStats(text) { statsLine.textContent = text; },
+ setRecording(recording) {
+ recordButton.setLabel('record', recording ? '録画を停止' : '録画を開始');
+ },
+ };
+}