import { OrbitControls, Text } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import CellLabels from "./CellLabels.jsx";
import { CUBE, makeLayout } from "../layout.js";
import { latticeAxisOf } from "../spec.js";
const BASE = new THREE.Color("#3d4d74");
const SWEPT = new THREE.Color("#6d8ac9");
const FRONT = new THREE.Color("#ffc061");
const KERNEL = new THREE.Color("#9b7cff");
const JOINT = new THREE.Color("#45e3cd");
// How the wavefront reads. The front is a gaussian rather than a hard stripe,
// and what it leaves behind decays with distance instead of switching state:
// a sweep is a continuous process, and drawing it as two flat regions with a
// bright band between them said "three categories of cell" when the truth is
// one moving front and a fading memory of it.
// Sigma of the leading glow, in cells. Deliberately under one cell: a short
// axis has only a handful of slabs, so a sigma of ~1 lit three of four at
// once and the whole lattice read as "front". The wavefront has to stay
// narrower than the thing it moves through.
const FRONT_WIDTH = 0.5;
const TRAIL = 4.5; // cells; e-folding length of the wake
const SWELL = 0.34; // peak scale bump at the front
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _p = new THREE.Vector3();
const _s = new THREE.Vector3();
const _c = new THREE.Color();
// Per-frame wavefront colouring and scale. anim is a mutable ref shared with
// the app: { layer: int, progress: 0..1 } — progress lives outside React state
// so the animation never re-renders the tree.
function Lattice({ parsed, layout, anim }) {
const meshRef = useRef();
const count = parsed.cells.length;
const positions = useMemo(
() => parsed.cells.map((c) => layout.position(c)),
[parsed, layout],
);
// Seed the matrices once so a paused first frame is not a pile of cubes at
// the origin; the frame loop overwrites both matrix and colour anyway.
useEffect(() => {
const mesh = meshRef.current;
positions.forEach((p, i) => {
_m.compose(_p.set(p[0], p[1], p[2]), _q.identity(), _s.setScalar(1));
mesh.setMatrixAt(i, _m);
mesh.setColorAt(i, BASE);
});
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
}, [positions]);
useFrame(() => {
const mesh = meshRef.current;
if (!mesh) return;
const { spec } = parsed;
const layer = spec.layers[anim.current.layer];
const progress = anim.current.progress;
// The family, not the class name: a second kernel-family method would
// have sniffed as a scan and been drawn with a travelling wavefront.
const family = spec.nd_method.family;
const atOnce = family === "kernel" || family === "flatten";
const tint = family === "flatten" ? JOINT : KERNEL;
const axis = layer ? latticeAxisOf(layer, spec) : null;
// Both directionless cases get one *uniform* pulse. A travelling ripple
// would look better and would be a lie: these families have no order
// across cells, and the picture must not imply one.
const pulse = Math.sin(progress * Math.PI) ** 1.5;
const breathe = 0.5 - 0.5 * Math.cos(progress * 2 * Math.PI);
const size = axis === null ? 0 : parsed.shape[axis];
const front = layer && layer.reverse ? (1 - progress) * (size - 1) : progress * (size - 1);
for (let i = 0; i < count; i++) {
let swell = 0;
if (atOnce) {
_c.copy(BASE).lerp(tint, 0.72 * pulse);
swell = 0.5 * pulse;
} else if (axis === null) {
// A time sweep has no lattice direction; the whole grid breathes.
_c.copy(BASE).lerp(SWEPT, breathe);
swell = 0.4 * breathe;
} else {
const c = layout.axisCoord(parsed.cells[i], axis);
const d = c - front;
const behind = layer.reverse ? d > 0 : d < 0;
const glow = Math.exp(-((d / FRONT_WIDTH) ** 2));
// Behind the front stays *visibly* swept however far back it is —
// "which part of the axis is done" is the question the picture
// answers, and a wake that decays to nothing erases the answer. The
// decay rides on top of a floor instead of replacing it.
const wake = behind ? 0.55 + 0.45 * Math.exp(-Math.abs(d) / TRAIL) : 0;
_c.copy(BASE).lerp(SWEPT, wake).lerp(FRONT, glow);
swell = glow;
}
const p = positions[i];
_m.compose(
_p.set(p[0], p[1], p[2]),
_q.identity(),
_s.setScalar(1 + SWELL * swell),
);
mesh.setMatrixAt(i, _m);
mesh.setColorAt(i, _c);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
});
return (
);
}
// Direction arrow for the active layer's swept axis, flipped when reverse. It
// rides *with* the wavefront rather than sitting still, so the arrow and the
// glow are one gesture instead of two things to reconcile.
function SweepArrow({ parsed, layout, anim }) {
const groupRef = useRef();
useFrame(() => {
const g = groupRef.current;
if (!g) return;
const { spec } = parsed;
const layer = spec.layers[anim.current.layer];
const axis = layer ? latticeAxisOf(layer, spec) : null;
const family = spec.nd_method.family;
if (axis === null || family === "kernel" || family === "flatten") {
g.visible = false;
return;
}
g.visible = true;
const sd = layout.dimOf[axis];
const size = parsed.shape[axis];
const margin = 1.35;
const dir = layer.reverse ? -1 : 1;
// axis 1 renders downward, so its on-screen direction flips
const screenDir = sd === 1 ? -dir : dir;
// Ride the front, in the cells' own coordinates. Scaling the screen extent
// instead put the arrow at one end while the glow was at the other,
// because above rank 3 two lattice axes share a screen dimension.
const progress = anim.current.progress;
const front = (layer.reverse ? 1 - progress : progress) * (size - 1);
const along = layout.screenOffset(axis, front);
// Travel along the swept dimension, stand off along a different one, so
// the arrow never flies through the cells it is pointing at.
const p = [0, 0, 0];
p[sd] = along;
if (sd === 1) p[0] = layout.blockExtent[0] / 2 + margin;
else p[1] = layout.blockExtent[1] / 2 + margin;
g.position.set(p[0], p[1], p[2]);
g.rotation.set(0, 0, 0);
if (sd === 0) g.rotation.z = screenDir > 0 ? -Math.PI / 2 : Math.PI / 2;
if (sd === 1) g.rotation.z = screenDir > 0 ? 0 : Math.PI;
if (sd === 2) g.rotation.x = screenDir > 0 ? Math.PI / 2 : -Math.PI / 2;
// Sized to the model, not to the axis: a stride-sized arrow vanished on
// the inner axes, where the stride is one cell.
g.scale.setScalar(Math.max(2.2, layout.radius * 0.34) / 2.4);
});
return (
);
}
function AxisLabels({ parsed, layout }) {
const names = parsed.spec.lattice.names.filter((n) => n !== "time");
const labels = [];
// The camera pulls back in proportion to the model, so a fixed world-space
// size is not a fixed *apparent* size: 0.52 was a shout on a 5x7 lattice and
// unreadable on a rank-4 stack. Scaling with the radius keeps it constant.
const fontSize = Math.max(0.3, layout.radius * 0.075);
for (let i = 0; i < Math.min(3, layout.rank); i++) {
const sd = layout.dimOf[i];
const p = [0, 0, 0];
p[sd] = layout.blockExtent[sd] / 2 + 1.15;
if (sd === 1) p[sd] = -p[sd];
labels.push(
{names[i] ?? `dim${i}`}
,
);
}
return labels;
}
// Depth cues. Fog fades the far side of a rank-4 stack so the near blocks read
// as near — without it the dimensional stacking is a flat wall of cubes — and
// the floor gives the whole thing somewhere to stand.
function Depth({ layout }) {
const { scene } = useThree();
useEffect(() => {
const r = layout.radius;
scene.fog = new THREE.Fog("#080b11", r * 2.0, r * 6.2);
return () => {
scene.fog = null;
};
}, [scene, layout]);
const y = -layout.blockExtent[1] / 2 - 1.6;
return (
);
}
// Reposition the camera when the spec changes. The Canvas itself is never
// remounted — tearing down the WebGL context per sample is how you get
// "THREE.WebGLRenderer: Context Lost" and a permanently black scene.
function Recenter({ layout }) {
const camera = useThree((s) => s.camera);
const controls = useThree((s) => s.controls);
const size = useThree((s) => s.size);
useEffect(() => {
// Fit both axes, not just the vertical one. A fixed multiple of the radius
// frames the model only at a wide aspect: in a tall narrow pane the
// horizontal field of view is far smaller than the vertical, and a
// rank-4 stack — which is wide — fell entirely outside it and rendered a
// black screen. Refits on resize for the same reason.
const fovY = (camera.fov * Math.PI) / 180;
const aspect = Math.max(size.width / Math.max(size.height, 1), 0.01);
const fitV = layout.radius / Math.tan(fovY / 2);
const fitH = layout.radius / Math.tan(fovY / 2) / aspect;
const dist = Math.max(6, Math.max(fitV, fitH) * 1.15);
camera.position.set(dist * 0.62, dist * 0.43, dist * 0.66);
camera.lookAt(0, 0, 0);
camera.far = Math.max(1000, dist * 6);
camera.updateProjectionMatrix();
if (controls) {
controls.target.set(0, 0, 0);
controls.update();
}
}, [layout, camera, controls, size]);
return null;
}
export default function Scene({ parsed, anim, dataShow, cellData }) {
const layout = useMemo(() => makeLayout(parsed.shape), [parsed]);
const dist = Math.max(6, layout.radius * 2.4);
const [touched, setTouched] = useState(false);
return (
);
}