aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio')
-rw-r--r--public/bluebey-studio/src/faceArt.js3
-rw-r--r--public/bluebey-studio/src/main.js102
-rw-r--r--public/bluebey-studio/src/panel.js32
-rw-r--r--public/bluebey-studio/src/presets.js2
4 files changed, 109 insertions, 30 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 482ddd7..adecbb9 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -131,7 +131,7 @@ export const EYE_LAYOUT = {
},
arch: { edgesUp: 8, apexUp: 44 },
// まつげ: a few strokes flicking out from the upper-outer lid (see drawEyes).
- lash: { length: 34, width: 9, angles: [26, 52, 78] },
+ lash: { length: 30, width: 8, angles: [46, 66, 86] },
};
/**
@@ -1463,7 +1463,6 @@ export function drawEyes(ctx, p) {
);
let travelBase = irisRadius;
if (isHeart) travelBase = heartRadius;
- else if (isShutShape && showWhite) travelBase = shapeHalfLimit;
const eyeTravel = Math.max(0, EYE_LAYOUT.radius - travelBase) * lookMax;
let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel;
let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel;
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 849d3d1..322a0e6 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -1,5 +1,6 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { loadModel } from './model.js';
import { Face } from './face.js';
import { Styles, isLineStyle } from './styles.js';
@@ -380,6 +381,8 @@ async function init() {
let hatKindCurrent = null;
let hatObject = null;
+ /** A GLB hat the user loaded; kept so it can be re-chosen without re-importing. */
+ let customHatObject = null;
const hatMeshes = [];
/** Last transform applied, so the render loop only wakes when it changes. */
const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null };
@@ -388,32 +391,52 @@ async function init() {
/** Set once the style system exists, so a hat can register for styles + outline. */
let hatStyleRegister = null;
let hatStyleUnregister = null;
- function applyHat(kind) {
- if (!hatMount) return;
- if (kind !== hatKindCurrent) {
- hatKindCurrent = kind;
- if (hatObject) {
- hatObject.traverse((o) => { if (o.isMesh) hatStyleUnregister?.(o); });
- hatMount.remove(hatObject);
- hatObject.traverse((o) => {
- o.geometry?.dispose();
- if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
- else o.material?.dispose();
- });
- hatObject = null;
+
+ /**
+ * Swap the mounted hat for `built` (or clear it), keeping the style system and
+ * the outline exclusion in step.
+ *
+ * The old meshes are collected BEFORE they are unregistered: `removeMesh`
+ * deletes the outline hull (a child of the hat group) while we traverse, which
+ * used to shorten `children` mid-loop and call `.traverse` on `undefined`.
+ */
+ function mountHat(built) {
+ if (hatObject) {
+ const old = [];
+ hatObject.traverse((o) => { if (o.isMesh) old.push(o); });
+ hatMount.remove(hatObject);
+ for (const mesh of old) hatStyleUnregister?.(mesh);
+ // The custom model is kept so it can be re-chosen; a built hat is dropped.
+ if (hatObject !== customHatObject) {
+ for (const mesh of old) {
+ mesh.geometry?.dispose();
+ if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose());
+ else mesh.material?.dispose();
+ }
}
- hatMeshes.length = 0;
- const built = buildHat(kind);
- if (built) {
- hatMount.add(built);
- hatObject = built;
- built.traverse((o) => {
- if (!o.isMesh) return;
- hatMeshes.push(o);
- hatStyleRegister?.(o);
- });
+ hatObject = null;
+ }
+ hatMeshes.length = 0;
+ if (built) {
+ hatMount.add(built);
+ hatObject = built;
+ const meshes = [];
+ built.traverse((o) => { if (o.isMesh) meshes.push(o); });
+ for (const mesh of meshes) {
+ hatMeshes.push(mesh);
+ hatStyleRegister?.(mesh);
}
- refreshOutlineExclusion?.();
+ }
+ refreshOutlineExclusion?.();
+ }
+
+ function applyHat(kind) {
+ if (!hatMount) return;
+ const custom = state.face.hat?.custom === true && customHatObject;
+ const key = custom ? 'custom' : kind;
+ if (key !== hatKindCurrent) {
+ hatKindCurrent = key;
+ mountHat(custom ? customHatObject : buildHat(kind));
}
// 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the
@@ -438,6 +461,36 @@ async function init() {
app.needsRender = true;
}
}
+ /**
+ * Import a GLB hat. It is centred sideways, dropped so its base sits at y = 0
+ * (the mount sits on the head), and scaled to about the head's width, then
+ * mounted like any built hat.
+ */
+ async function loadHatModel(file) {
+ if (!hatMount) return;
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const loader = new GLTFLoader();
+ const gltf = await new Promise((resolve, reject) => loader.parse(buffer, '', resolve, reject));
+ const root = gltf.scene;
+ const box = new THREE.Box3().setFromObject(root);
+ const size = box.getSize(new THREE.Vector3());
+ const target = Math.max(0.2, model.size.x * 0.95);
+ const scale = target / Math.max(size.x, size.z, 0.001);
+ root.scale.setScalar(scale);
+ box.setFromObject(root);
+ const center = box.getCenter(new THREE.Vector3());
+ root.position.x -= center.x;
+ root.position.z -= center.z;
+ root.position.y -= box.min.y;
+ root.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; } });
+ customHatObject = root;
+ state.face.hat.custom = true;
+ state.face.hat.kind = 'none';
+ hatKindCurrent = null;
+ refresh('all');
+ app.panel?.sync();
+ }
+
applyHat(state.face.hat?.kind ?? 'none');
const halfHeight = model.size.y * 0.5;
@@ -1428,6 +1481,7 @@ async function init() {
capturePose,
applyState,
refresh,
+ loadHatModel,
patch: (scope, value) => applyState({ [scope]: value }, { scope }),
syncPanel: () => app.panel?.sync(),
imageCanvas: (options) => captureImage(options),
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 2b762f1..093386f 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -1674,15 +1674,41 @@ export function buildPanel(app, root) {
/* ------------------------------------------------------------- 帽子 */
const hatSection = section(tabView, '帽子');
- hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。もう一度「なし」を選ぶと外れます。'));
+ hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。'));
const hatSegment = segmented({
label: '帽子',
options: HAT_LIBRARY.map((hat) => ({ value: hat.id, label: hat.label })),
value: 'none',
- onChange: (value) => { state.face.hat.kind = value; app.actions.refresh('all'); },
+ onChange: (value) => {
+ state.face.hat.kind = value;
+ // Picking one of the built-in hats (including なし) also clears any GLB hat
+ // the user imported, so なし really removes the hat.
+ state.face.hat.custom = false;
+ app.actions.refresh('all');
+ },
});
hatSection.add(hatSegment.el);
- syncers.push(() => hatSegment.set(state.face.hat?.kind ?? 'none'));
+ syncers.push(() => hatSegment.set(state.face.hat?.custom === true ? 'none' : (state.face.hat?.kind ?? 'none')));
+
+ // 帽子モデル(GLB)の取り込み。頭に乗るよう自動で位置と大きさを合わせる。
+ const hatFilePicker = h('input', { type: 'file', accept: '.glb,.gltf,model/gltf-binary,model/gltf+json', style: { display: 'none' } });
+ hatFilePicker.addEventListener('change', async () => {
+ const file = hatFilePicker.files?.[0];
+ hatFilePicker.value = '';
+ if (!file) return;
+ try {
+ await app.actions.loadHatModel(file);
+ toast(`帽子「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('帽子モデルを読み込めませんでした(GLBをお使いください)');
+ }
+ });
+ hatSection.add(hatFilePicker);
+ hatSection.add(buttons({
+ items: [{ id: 'hatfile', label: '帽子モデルを読み込む(GLB)', onClick: () => hatFilePicker.click() }],
+ }));
+ hatSection.add(hint('GLBの帽子を頭に乗せます。大きさと位置は自動で合わせます。'));
const hatBox = details(hatSection.body, '位置と傾き');
const hatSliders = {
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 90b249a..8644215 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -196,7 +196,7 @@ export function defaultState() {
// 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
// `x`/`z` slide it sideways / forwards, `height` lifts it off the head;
// `tiltX` leans it forward/back and `tiltZ` leans it sideways.
- hat: { kind: 'none', x: 0, z: 0, height: 0, tiltX: 0, tiltZ: 0 },
+ hat: { kind: 'none', custom: false, x: 0, z: 0, height: 0, tiltX: 0, tiltZ: 0 },
eyes: {
source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
linked: true, // move both eyes together