diff --git a/frontend/src/lib/zen/aquarium.js b/frontend/src/lib/zen/aquarium.js index e7c2eb4..11531d1 100644 --- a/frontend/src/lib/zen/aquarium.js +++ b/frontend/src/lib/zen/aquarium.js @@ -19,7 +19,8 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; const MODEL_URL = '/models/ub-angelfish.glb'; // UB is now a marine angelfish (Queen): ONE mesh, TWO materials (…_body, …_fins — fins -// covers the caudal tail too), and a single trimmed Idle loop. Body renders OPAQUE + +// covers the caudal tail too), and a single trimmed loop (Swim1_norm — a ~2.5s in-place +// swim cycle, zero root drift, so it undulates without traveling). Body renders OPAQUE + // single-sided; fins default to OPAQUE alpha-tested (clean silhouette, no blend bleed — // the koi's lesson) but can go translucent. All live-tunable at /zen?debug=1. export const DEFAULTS = { @@ -120,7 +121,7 @@ export async function createAquarium(canvas, initial = {}) { const mixer = new THREE.AnimationMixer(ub); const actions = {}; for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip); - const baseClip = mixer.clipAction(gltf.animations[0]); // the trimmed Idle loop + const baseClip = mixer.clipAction(gltf.animations[0]); // the trimmed swim loop baseClip.play(); const baseDuration = baseClip.getClip().duration || 1; mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion diff --git a/frontend/static/models/ub-angelfish.glb b/frontend/static/models/ub-angelfish.glb index 54ba103..68d302c 100644 Binary files a/frontend/static/models/ub-angelfish.glb and b/frontend/static/models/ub-angelfish.glb differ diff --git a/tools/glb-split/trim-clip.mjs b/tools/glb-split/trim-clip.mjs new file mode 100644 index 0000000..8e39c55 --- /dev/null +++ b/tools/glb-split/trim-clip.mjs @@ -0,0 +1,56 @@ +// Extract ONE clip [tStart,tEnd] (seconds) from a GLB's baked Take, rebased to start at 0, +// and report the root joint's net translation over the clip (so we know if a swim cycle +// drifts the fish through space vs. swims in place). Usage: +// node trim-clip.mjs in.glb out.glb 3.667 6.167 +import { NodeIO } from '@gltf-transform/core'; +import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; +import { prune } from '@gltf-transform/functions'; + +const [, , inPath, outPath, s0, s1] = process.argv; +const tStart = parseFloat(s0), tEnd = parseFloat(s1), EPS = 1e-4; +const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); +const doc = await io.read(inPath); +const root = doc.getRoot(); + +// Pass 1: per unique input, find the kept index window + rebase times to 0. +const win = new Map(); // input -> {a,b} +for (const anim of root.listAnimations()) + for (const sm of anim.listSamplers()) { + const input = sm.getInput(); + if (win.has(input)) continue; + const t = input.getArray(); + let a = 0; while (a < t.length - 1 && t[a] < tStart - EPS) a++; + let b = t.length - 1; while (b > a && t[b] > tEnd + EPS) b--; + win.set(input, { a, b }); + const base = t[a]; + input.setArray(t.slice(a, b + 1).map((x) => x - base)); + } +// Pass 2: slice each output to its input's window. +for (const anim of root.listAnimations()) + for (const sm of anim.listSamplers()) { + const { a, b } = win.get(sm.getInput()); + const out = sm.getOutput(); + const c = out.getElementSize(); + out.setArray(out.getArray().slice(a * c, (b + 1) * c)); + } + +// Report root-joint translation drift over the clip (first joint's translation track). +const skin = root.listSkins()[0]; +const rootJoint = skin?.listJoints()[0]; +let drift = 'n/a'; +if (rootJoint) { + for (const anim of root.listAnimations()) + for (const ch of anim.listChannels()) { + if (ch.getTargetNode() === rootJoint && ch.getTargetPath() === 'translation') { + const o = ch.getSampler().getOutput().getArray(); + const n = o.length / 3; + const dx = o[(n - 1) * 3] - o[0], dy = o[(n - 1) * 3 + 1] - o[1], dz = o[(n - 1) * 3 + 2] - o[2]; + drift = `Δ[${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}] (model units)`; + } + } +} +await doc.transform(prune()); +await io.write(outPath, doc); +const a = root.listAnimations()[0]; +let tmax = 0; for (const ch of a.listChannels()) { const arr = ch.getSampler().getInput().getArray(); tmax = Math.max(tmax, arr[arr.length - 1]); } +console.log(`wrote ${outPath} | clip duration ${tmax.toFixed(2)}s | root drift ${drift}`);