File size: 6,475 Bytes
ecc81b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | import { Text } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { latticeAxisOf } from "../spec.js";
// How many labels exist at once. Not "how many cells have labels" — every cell
// has one, but only the nearest POOL of them are drawn. A 4-D lattice can hold
// thousands of cells and each label is a text mesh with its own glyph layout;
// building one per cell drops the frame rate to single digits and produces an
// unreadable thicket besides. Distance decides who gets one.
const POOL = 56;
const RECHECK = 0.12; // seconds between reassignments
const CAM_STEP = 0.4; // or immediately, once the camera has moved this far
const MIN_SEP = 0.115; // NDC; closest two labels may sit before one is dropped
const _v = new THREE.Vector3();
export default function CellLabels({ parsed, layout, anim, enabled, cellData }) {
const refs = useRef([]);
const lastCam = useRef(new THREE.Vector3(Infinity, 0, 0));
const since = useRef(0);
const shown = useRef([]);
// Axis names line up with `shape`, which is spatial only — `names` carries
// the time axis in front of them when the lattice has one.
const names = useMemo(() => {
const all = parsed.spec.lattice.names ?? [];
const spatial = all.slice(parsed.spec.lattice.time ? 1 : 0);
return parsed.shape.map((_, i) => spatial[i] ?? `dim${i}`);
}, [parsed]);
const positions = useMemo(
() => parsed.cells.map((c) => layout.position(c)),
[parsed, layout],
);
const coordText = useMemo(
() => parsed.cells.map((c) => names.map((n, i) => `${n} ${c[i]}`).join("\n")),
[parsed, names],
);
// With a run attached the label carries what the cell actually holds this
// step, under its coordinates. `cellData` arrives present-only and in the
// same flat order parseSpec built `cells`, so the index is shared; a length
// that disagrees means the run and the spec are out of step, and showing
// numbers against the wrong cells would be worse than showing none.
const aligned =
cellData &&
cellData.pred &&
cellData.pred.length === parsed.cells.length;
useFrame((state, dt) => {
const pool = refs.current;
if (!pool.length) return;
if (!enabled) {
// Hide once, then stop doing work every frame.
if (shown.current.length) {
pool.forEach((t) => t && (t.visible = false));
shown.current = [];
}
return;
}
const cam = state.camera;
since.current += dt;
const moved = cam.position.distanceTo(lastCam.current);
const restack = since.current > RECHECK || moved > CAM_STEP;
if (restack) {
since.current = 0;
lastCam.current.copy(cam.position);
// Nearest first...
const scored = [];
for (let i = 0; i < positions.length; i++) {
const p = positions[i];
_v.set(p[0], p[1], p[2]);
scored.push([_v.distanceTo(cam.position), i]);
}
scored.sort((a, b) => a[0] - b[0]);
// ...then thinned in *screen* space. Taking the nearest N by distance
// alone piles them all into one corner of the frame and the result is a
// thicket you cannot read a single line of. Skipping any candidate that
// lands too close to one already placed keeps every drawn label legible,
// and means zooming in reveals more of them — cells spread apart on
// screen, so more of them clear the spacing test. Behind the camera is
// skipped outright: a label back there is one spent on nothing.
const kept = [];
for (const [dist, i] of scored) {
if (kept.length >= POOL) break;
const p = positions[i];
_v.set(p[0], p[1], p[2]).project(cam);
if (_v.z > 1) continue;
let clear = true;
for (const k of kept) {
if (Math.hypot((_v.x - k.x) * 0.55, _v.y - k.y) < MIN_SEP) {
clear = false;
break;
}
}
if (clear) kept.push({ x: _v.x, y: _v.y, i, dist });
}
shown.current = kept.map((k) => [k.dist, k.i]);
}
// The layer's swept axis decides each label's tint, so a label says which
// side of the wavefront its cell is on rather than only where it sits.
const { spec } = parsed;
const layer = spec.layers[anim.current.layer];
const family = spec.nd_method.family;
const axis =
layer && family !== "kernel" && family !== "flatten"
? latticeAxisOf(layer, spec)
: null;
const size = axis === null ? 0 : parsed.shape[axis];
const front =
layer && layer.reverse
? (1 - anim.current.progress) * (size - 1)
: anim.current.progress * (size - 1);
for (let k = 0; k < POOL; k++) {
const t = pool[k];
if (!t) continue;
const entry = shown.current[k];
if (!entry) {
t.visible = false;
continue;
}
const [dist, i] = entry;
const p = positions[i];
t.visible = true;
t.position.set(p[0], p[1] + 0.52, p[2]);
t.quaternion.copy(cam.quaternion); // face the camera
const label = aligned
? `${coordText[i]}\npred ${cellData.pred[i]}\ntrue ${cellData.true[i]}`
: coordText[i];
if (t.text !== label) t.text = label;
// Fade with distance instead of popping in and out at the pool edge.
const near = layout.radius * 1.1;
const far = layout.radius * 2.6;
t.fillOpacity = 1 - THREE.MathUtils.clamp((dist - near) / (far - near), 0, 0.92);
if (axis === null) {
t.color = "#9fb0cc";
} else {
const d = parsed.cells[i][axis] - front;
const behind = layer.reverse ? d > 0 : d < 0;
t.color = Math.abs(d) < 0.75 ? "#ffd79a" : behind ? "#9fc4ff" : "#7d8aa3";
}
}
});
// Scaled to the model for the same reason the axis labels are: the camera
// pulls back in proportion to the lattice, so a fixed world size is not a
// fixed readable size.
const fontSize = Math.max(0.15, layout.radius * 0.032);
return (
<group>
{Array.from({ length: POOL }, (_, i) => (
<Text
key={i}
ref={(el) => (refs.current[i] = el)}
visible={false}
fontSize={fontSize}
lineHeight={1.25}
anchorX="center"
anchorY="bottom"
outlineWidth={0.014}
outlineColor="#080b11"
>
{""}
</Text>
))}
</group>
);
}
|