diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js index b9be31f..4fdca52 100644 --- a/public/js/ghosts/loader.js +++ b/public/js/ghosts/loader.js @@ -83,7 +83,6 @@ export async function buildGhost(ghost, gradients, manifest) { if (!url) continue; group.add(await loadOBJ(url)); } - if (model.scale) group.scale.setScalar(model.scale); if (!group.children.length) group = proceduralWisp(); } catch (e) { console.warn('ghost model load failed, using wisp fallback', e); @@ -103,8 +102,20 @@ export async function buildGhost(ghost, gradients, manifest) { } }); - group.userData.material = mat; - group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; }; - group.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; - return group; + // Normalize: wrap so feet sit at the group origin, then expose setHeight(cm) that + // scales the whole ghost (any source units) to an exact world height. Minifigure ≈ 4 cm. + const outer = new THREE.Group(); + const inner = new THREE.Group(); + inner.position.y = -box.min.y; // feet -> origin + inner.add(group); + outer.add(inner); + const rawH = Math.max(box.max.y - box.min.y, 1e-6); + const modelScale = model?.scale || 1; // per-model fine-tune multiplier from the manifest + outer.userData.setHeight = (cm) => outer.scale.setScalar((cm / rawH) * modelScale); + outer.userData.setHeight(4); + + outer.userData.material = mat; + outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; }; + outer.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; + return outer; }