diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js index da7fa14..a3c283e 100644 --- a/public/js/ghosts/loader.js +++ b/public/js/ghosts/loader.js @@ -6,7 +6,14 @@ * * Per-character overrides come from /api/characters (managed in /admin/characters.html): * { modelId, opacity, topColor, bottomColor, heightCm, scale, - * faceTextureUrl, torsoTextureUrl } + * faceTextureUrl, torsoTextureUrl, + * partOverrides: { : { offset:[x,y,z], rotation:[x,y,z], scale } } } + * + * Part placement: each part in a model may be a bare URL string, or an object + * { url, offset:[x,y,z], rotation:[x,y,z](degrees), scale } + * The model manifest holds the shared default placement; a character's + * partOverrides[key] is merged on top so an individual ghost can be nudged + * without disturbing every other ghost that shares the same model. * * Textures: face and torso images are projected planar-front onto the mesh (UV-independent, * so untextured OBJs still work). Each is placed by a normalized Y band with adjustable @@ -15,6 +22,9 @@ import * as THREE from 'three'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; +/* Canonical part order (also the assembly/z-order). Shared with the editor. */ +export const PART_KEYS = ['legs', 'wisp', 'torso', 'head', 'headpiece']; + const objLoader = new OBJLoader(); const objCache = new Map(); const texLoader = new THREE.TextureLoader(); @@ -50,6 +60,7 @@ function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) { opacity: { value: opacity }, uMinY: { value: 0 }, uMaxY: { value: 1 }, + uToGroup: { value: new THREE.Matrix4() }, // mesh-local -> group-local (bakes part transform) uTime: { value: 0 }, faceMap: { value: faceTex || EMPTY_TEX }, torsoMap: { value: torsoTex || EMPTY_TEX }, @@ -64,10 +75,14 @@ function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) { varying vec3 vNormal; varying vec3 vLocal; uniform float uMinY, uMaxY; + uniform mat4 uToGroup; void main() { + // Position within the assembled group (accounts for per-part offset/rotation/scale), + // so the gradient + decal bands span the whole figure, not each part's raw origin. + vec3 gp = (uToGroup * vec4(position, 1.0)).xyz; float h = max(uMaxY - uMinY, 0.001); - vY = clamp((position.y - uMinY) / h, 0.0, 1.0); - vLocal = vec3(position.x / h, vY, position.z / h); + vY = clamp((gp.y - uMinY) / h, 0.0, 1.0); + vLocal = vec3(gp.x / h, vY, gp.z / h); vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, @@ -118,6 +133,50 @@ async function loadOBJ(url) { return obj.clone(); } +const DEG = Math.PI / 180; + +/* A part entry may be a bare URL string or { url, offset, rotation, scale }. + * Return a normalized { url, offset:[x,y,z], rotation:[x,y,z], scale } or null. */ +export function normalizePart(entry) { + if (!entry) return null; + if (typeof entry === 'string') { + return { url: entry, offset: [0, 0, 0], rotation: [0, 0, 0], scale: 1 }; + } + if (!entry.url) return null; + const o = Array.isArray(entry.offset) ? entry.offset : [0, 0, 0]; + const r = Array.isArray(entry.rotation) ? entry.rotation : [0, 0, 0]; + return { + url: entry.url, + offset: [+o[0] || 0, +o[1] || 0, +o[2] || 0], + rotation: [+r[0] || 0, +r[1] || 0, +r[2] || 0], + scale: entry.scale != null ? (+entry.scale || 1) : 1, + }; +} + +/* Merge a per-ghost override on top of the model's default placement. + * Only fields present in the override replace the base; url always wins from base. */ +export function mergePart(base, override) { + const b = normalizePart(base); + if (!b) return null; + if (!override) return b; + const ov = override; + return { + url: b.url, + offset: Array.isArray(ov.offset) ? ov.offset.map((v, i) => (v != null ? +v : b.offset[i])) : b.offset, + rotation: Array.isArray(ov.rotation) ? ov.rotation.map((v, i) => (v != null ? +v : b.rotation[i])) : b.rotation, + scale: ov.scale != null ? +ov.scale : b.scale, + }; +} + +/* Apply a normalized transform to a freshly-loaded part group. */ +function applyPartTransform(obj, p) { + obj.position.set(p.offset[0], p.offset[1], p.offset[2]); + obj.rotation.set(p.rotation[0] * DEG, p.rotation[1] * DEG, p.rotation[2] * DEG); + obj.scale.setScalar(p.scale); + obj.userData.partKey = p.key; + return obj; +} + function proceduralWisp() { const g = new THREE.Group(); const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16)); @@ -162,13 +221,17 @@ export async function buildGhost(ghost, gradients, manifest, characters) { let group; const model = pickModel(manifest, ov); + const partOv = ov.partOverrides || {}; if (model && model.parts) { group = new THREE.Group(); try { - for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) { - const url = model.parts[key]; - if (!url) continue; - group.add(await loadOBJ(url)); + for (const key of PART_KEYS) { + const merged = mergePart(model.parts[key], partOv[key]); + if (!merged) continue; + merged.key = key; + const partObj = await loadOBJ(merged.url); + applyPartTransform(partObj, merged); + group.add(partObj); } if (!group.children.length) group = proceduralWisp(); } catch (e) { @@ -179,15 +242,24 @@ export async function buildGhost(ghost, gradients, manifest, characters) { group = proceduralWisp(); } - // gradient/decal mapping needs the model's Y range + // gradient/decal mapping needs the model's Y range (group-local, post part-transform) + group.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(group); + const groupInv = new THREE.Matrix4().copy(group.matrixWorld).invert(); + const mats = []; group.traverse(o => { if (o.isMesh) { - o.material = mat; - o.material.uniforms.uMinY.value = box.min.y; - o.material.uniforms.uMaxY.value = box.max.y; + // Each mesh needs its own material instance to carry a per-mesh group-local matrix, + // but they all share the same tunable uniforms via the tick/opacity setters below. + const m = mat.clone(); + m.uniforms.uMinY.value = box.min.y; + m.uniforms.uMaxY.value = box.max.y; + m.uniforms.uToGroup.value = new THREE.Matrix4().multiplyMatrices(groupInv, o.matrixWorld); + o.material = m; + mats.push(m); } }); + if (!mats.length) mats.push(mat); // Normalize: feet at origin, then setHeight(cm) scales any source units to world height. const outer = new THREE.Group(); @@ -200,8 +272,9 @@ export async function buildGhost(ghost, gradients, manifest, characters) { outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale); outer.userData.setHeight(4); - outer.userData.material = mat; - outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v * opacity; }; - outer.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; + outer.userData.material = mats[0]; + outer.userData.materials = mats; + outer.userData.setOpacity = (v) => { for (const m of mats) m.uniforms.opacity.value = v * opacity; }; + outer.userData.tick = (t) => { for (const m of mats) m.uniforms.uTime.value = t; }; return outer; }