diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 14:51:36 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 14:51:36 +0900 |
| commit | 257516e2bde90e65439d01f19434cd2d9d5a03f7 (patch) | |
| tree | cab680958a6bb1249c288d5974b083cfa9de1f3f /public/bluebey-studio/src/main.js | |
| parent | 1bf7ee115176bc366315a90ab31740e6cd968744 (diff) | |
bluebey-studio: 帽子切替のエラー修正とGLB帽子インポート、閉じ目の目線範囲・まつげ位置を調整
帽子切替時にトラバース中でハルを外して子がundefinedになる不具合を修正。GLBの帽子モデルを読み込めるようにし、なしで解除できるようにした。閉じ目の目線範囲を通常並みに広げ、まつげを上まぶたの外側へ配置。
Diffstat (limited to 'public/bluebey-studio/src/main.js')
| -rw-r--r-- | public/bluebey-studio/src/main.js | 102 |
1 files changed, 78 insertions, 24 deletions
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), |
