diff options
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 3 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 102 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 32 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 2 |
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 |
