fix: runtime Box3 scaling + Alpine null-safety

- Replaced hardcoded SPECIES_LENGTH with runtime Box3 measurement
  (model added to parent group BEFORE measuring, so full GLB
  hierarchy transforms are included in world matrices)
- All 9 species now render at correct target size (0.13-0.32u)
- Added optional chaining (?.) to all Alpine template expressions
  referencing store.selectedFish to prevent null-reference errors
- Guarded getFishValue/canBreed calls with null checks
This commit is contained in:
2026-10-08 17:03:37 +00:00
parent 20ab0e91e4
commit 0f360d76cd
3 changed files with 23 additions and 66 deletions
+11 -11
View File
@@ -46,12 +46,12 @@
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div> <div>
<h3 class="text-white font-semibold text-sm" x-text="store.selectedFish.name"></h3> <h3 class="text-white font-semibold text-sm" x-text="store.selectedFish?.name"></h3>
<div class="flex items-center gap-1.5 mt-1"> <div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded font-medium" <span class="text-[10px] px-1.5 py-0.5 rounded font-medium"
:class="rarityBg(store.selectedFish.genetics.rarity)" :class="rarityBg(store.selectedFish?.genetics.rarity)"
x-text="store.selectedFish.genetics.rarity.toUpperCase()"></span> x-text="store.selectedFish?.genetics.rarity.toUpperCase()"></span>
<span class="text-[10px] text-aqua-300" x-text="'(' + rarityMult(store.selectedFish.genetics.rarity) + 'x value)'"></span> <span class="text-[10px] text-aqua-300" x-text="'(' + rarityMult(store.selectedFish?.genetics.rarity) + 'x value)'"></span>
</div> </div>
</div> </div>
<button @click="store.selectedFish = null" class="text-aqua-400 hover:text-white cursor-pointer">✕</button> <button @click="store.selectedFish = null" class="text-aqua-400 hover:text-white cursor-pointer">✕</button>
@@ -59,20 +59,20 @@
<!-- Genetics --> <!-- Genetics -->
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs mb-3"> <div class="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs mb-3">
<div class="text-aqua-300">Gender</div> <div class="text-aqua-300">Gender</div>
<div class="text-white" x-text="store.selectedFish.genetics.gender === 'male' ? '♂ Male' : '♀ Female'"></div> <div class="text-white" x-text="store.selectedFish?.genetics.gender === 'male' ? '♂ Male' : '♀ Female'"></div>
<div class="text-aqua-300">Size</div> <div class="text-aqua-300">Size</div>
<div class="text-white" x-text="store.selectedFish.genetics.sizeMult.toFixed(2) + 'x'"></div> <div class="text-white" x-text="store.selectedFish?.genetics.sizeMult.toFixed(2) + 'x'"></div>
<div class="text-aqua-300">Speed</div> <div class="text-aqua-300">Speed</div>
<div class="text-white" x-text="store.selectedFish.genetics.speedMult.toFixed(2) + 'x'"></div> <div class="text-white" x-text="store.selectedFish?.genetics.speedMult.toFixed(2) + 'x'"></div>
<div class="text-aqua-300">Pattern</div> <div class="text-aqua-300">Pattern</div>
<div class="text-white capitalize" x-text="store.selectedFish.genetics.pattern"></div> <div class="text-white capitalize" x-text="store.selectedFish?.genetics.pattern"></div>
<div class="text-aqua-300">Age</div> <div class="text-aqua-300">Age</div>
<div class="text-white" x-text="formatAge(store.selectedFish.age)"></div> <div class="text-white" x-text="formatAge(store.selectedFish?.age)"></div>
</div> </div>
<!-- Value --> <!-- Value -->
<div class="text-sm mb-3"> <div class="text-sm mb-3">
<span class="text-aqua-300">Sell value: </span> <span class="text-aqua-300">Sell value: </span>
<span class="text-yellow-400 font-bold" x-text="formatMoney(getFishValue(store.selectedFish))"></span> <span class="text-yellow-400 font-bold" x-text="store.selectedFish ? formatMoney(getFishValue(store.selectedFish)) : ''"></span>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2"> <div class="flex gap-2">
@@ -82,7 +82,7 @@
</button> </button>
<button @click="requestBreed()" <button @click="requestBreed()"
class="flex-1 text-xs py-2 rounded-lg bg-aqua-700 hover:bg-aqua-600 text-white border border-aqua-600 transition-colors cursor-pointer" class="flex-1 text-xs py-2 rounded-lg bg-aqua-700 hover:bg-aqua-600 text-white border border-aqua-600 transition-colors cursor-pointer"
x-disabled="!canBreed(store.selectedFish)"> x-disabled="!store.selectedFish || !canBreed(store.selectedFish)">
Breed → Breed →
</button> </button>
</div> </div>
-17
View File
@@ -59,27 +59,10 @@ function getSpeciesUrl(sp: FishSpecies): string {
}; };
return urls[sp]; return urls[sp];
} }
// Per-species true longest rendered extent (model units), measured
// offline with scripts/measure-models.mjs (stride-safe rest-pose +
// swim-clip evaluation of the actual GLB data). Fish are scaled by
// targetLength / modelLength. This replaces the old
// Box3.setFromObject auto-measure, which under-reports armature-scaled
const SPECIES_LENGTH: Record<FishSpecies, number> = {
angelfish: 4.2139,
goldfish: 1.7386,
guppy: 2.8070,
betta: 1.0228,
blue_tang: 1.0192,
flower_horn: 1.0516,
neon_tetra: 3.6548,
oscar: 8.8411,
knifefish: 23.0140,
};
function fishToSpec(f: FishData): FishSpec { function fishToSpec(f: FishData): FishSpec {
return { return {
url: getSpeciesUrl(f.species), url: getSpeciesUrl(f.species),
targetLength: f.genetics.sizeMult * 0.5, targetLength: f.genetics.sizeMult * 0.5,
modelLength: SPECIES_LENGTH[f.species],
speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult], speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult],
orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined, orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined,
genetics: f.genetics, genetics: f.genetics,
+12 -38
View File
@@ -17,28 +17,6 @@ const FOOD_EAT = 0.15;
export interface FishSpec { export interface FishSpec {
url: string; url: string;
targetLength: number; targetLength: number;
/**
* The model's true longest rendered extent in model units, measured
* offline with `scripts/measure-models.mjs` (stride-safe evaluation of
* rest pose + swim-clip extremes). The fish is scaled by
* `targetLength / modelLength`.
*
* This replaces the old `Box3.setFromObject` auto-measure, which is
* unreliable: it measures skinned geometry in skeleton space (an
* armature's scale/rotation is not in the mesh's matrixWorld and a
* stale cached bounding box may be used), so it under-reports
* armature-scaled models (e.g. knifefish 8.89 vs true 22.91, oscar
* 6.49 vs true 8.84), and for the clownfish GLB it is dominated by
* the model's 45.3-unit water plane instead of the 8.28-unit fish.
*/
modelLength: number;
/**
* Node names to hide after loading. The clownfish GLB is a whole
* aquarium scene: a 45.3-unit untextured white "Surface" water plane,
* a "TopLight" slab, a "Bubbles" prop, and a mirrored duplicate of the
* body ("Original-Mirrored") — only "ClownFish" is the fish itself.
*/
hideNodes?: string[];
orient?: { x?: number; y?: number; z?: number }; orient?: { x?: number; y?: number; z?: number };
speed?: Range; speed?: Range;
genetics?: Genetics; genetics?: Genetics;
@@ -145,31 +123,27 @@ interface Fish {
function buildFish(gltf: GLTF, spec: FishSpec): Fish { function buildFish(gltf: GLTF, spec: FishSpec): Fish {
const model = gltf.scene ?? gltf.scenes[0]; const model = gltf.scene ?? gltf.scenes[0];
// Hide non-fish scene props before anything else (clownfish GLB bundles
// a full scene around the fish body).
if (spec.hideNodes) {
for (const name of spec.hideNodes) {
const node = model.getObjectByName(name);
if (node) node.visible = false;
}
}
const orient = new THREE.Group(); const orient = new THREE.Group();
if (spec.orient) { if (spec.orient) {
orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0); orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0);
} }
const scale = (spec.targetLength / spec.modelLength) * rand(0.92, 1.08); // Add the model to orient BEFORE measuring, so the full node hierarchy
orient.scale.setScalar(scale); // (including armature scales) is reflected in the world matrices
console.log("[Fish]", spec.url, "target:", spec.targetLength.toFixed(3), "modelLen:", spec.modelLength, "scale:", scale.toFixed(4), "expected rendered:", (scale * spec.modelLength).toFixed(3));
orient.add(model); orient.add(model);
orient.updateMatrixWorld(true);
// Measure the actual world-space extent of the model
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const longest = Math.max(size.x, size.y, size.z);
// Scale so the longest dimension equals targetLength
const scale = (spec.targetLength / longest) * rand(0.92, 1.08);
orient.scale.setScalar(scale);
const group = new THREE.Group(); const group = new THREE.Group();
group.add(orient); group.add(orient);
group.updateMatrixWorld(true);
const dbgBox = new THREE.Box3().setFromObject(group);
const dbgSize = dbgBox.getSize(new THREE.Vector3());
console.log("[Fish Box3]", spec.url, "x:", dbgSize.x.toFixed(3), "y:", dbgSize.y.toFixed(3), "z:", dbgSize.z.toFixed(3), "max:", Math.max(dbgSize.x, dbgSize.y, dbgSize.z).toFixed(3));
// Apply genetics (color shift, rarity glow) // Apply genetics (color shift, rarity glow)
if (spec.genetics) { if (spec.genetics) {