frontend/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx

254 lines
No EOL
7.7 KiB
TypeScript

import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { colourFade } from "bin/3dView/utils/tempToColour";
import { Bin } from "models";
import { useMemo } from "react";
import { CanvasTexture, NormalBlending } from "three";
interface Props {
bin: Bin;
nodes: NodeData[];
}
export default function NodePointCloud(props: Props) {
const { bin, nodes } = props;
// 🎛️ TUNING KNOBS
const BASE_RADIUS_FACTOR = 0.15;
const INTENSITY_DENSITY_MULT = 1.25;
const INTENSITY_DENSITY_BASE = 0.3;
const RADIAL_BASE = 6;
const THETA_BASE = 10;
const PHI_BASE = 10;
const MIN_EDGE_INTENSITY = 0.2;
const BASE_BRIGHTNESS = 0.7;
const INTENSITY_BRIGHTNESS_MULT = 0.7;
const OPACITY = 0.3;
const POINT_SIZE = 1;
// IDW power — how sharply nearer nodes dominate the field
const IDW_POWER = 2;
// Minimum net signed magnitude to emit a point.
// Keeps near-zero cancellation zones empty rather than noisy.
const EMIT_THRESHOLD = 0.05;
// -----------------------------
// BIN GEOMETRY
// -----------------------------
const binRadius = bin.diameter() / 2;
const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0;
const sidewallBaseY = -sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight;
const maxRadiusAtY = (y: number): number => {
if (y >= sidewallBaseY) return binRadius;
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
const t = (y - hopperTipY) / hopperHeight;
return binRadius * t;
};
// -----------------------------
// GRAIN LIMIT
// -----------------------------
const maxGrainY = useMemo(() => {
let maxY = -Infinity;
nodes.forEach((node) => {
if (!node.inGrain || node.excluded) return;
if (node.position.y > maxY) maxY = node.position.y;
});
return maxY;
}, [nodes]);
// -----------------------------
// PRE-COMPUTE SIGNED NODE CONTRIBUTIONS
// Only nodes outside threshold contribute to the field.
// Hot nodes carry a positive signed intensity, cold nodes negative.
// -----------------------------
const signedNodes = useMemo(() => {
return nodes
.filter(n => n.inGrain && !n.excluded)
.map(n => {
const lower = bin.lowerTempThreshold();
const upper = bin.upperTempThreshold();
if (n.celcius >= lower && n.celcius <= upper) return null;
const distance = n.celcius < lower
? lower - n.celcius
: n.celcius - upper;
const intensity = Math.min(1, distance / colourFade);
// positive = hot, negative = cold
const signedIntensity = n.celcius > upper ? intensity : -intensity;
return {
x: n.position.x,
y: n.position.y,
z: n.position.z,
signedIntensity,
cloudRadius: bin.diameter() * BASE_RADIUS_FACTOR * (0.5 + intensity),
densityMultiplier: INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT,
};
})
.filter(Boolean) as {
x: number; y: number; z: number;
signedIntensity: number;
cloudRadius: number;
densityMultiplier: number;
}[];
}, [nodes, bin]);
/**
* Evaluates the net signed field at (px, py, pz) by summing
* IDW-weighted signed intensities from all contributing nodes.
*
* Returns a value in [-1, 1]:
* > 0 → net hot
* < 0 → net cold
* ~0 → cancelled out — point will not be emitted
*/
const evaluateField = (px: number, py: number, pz: number): number => {
let totalWeight = 0;
let weightedSum = 0;
for (const node of signedNodes) {
const dx = px - node.x;
const dy = py - node.y;
const dz = pz - node.z;
const distSq = dx * dx + dy * dy + dz * dz;
const weight = distSq < 0.001
? 1e6
: 1 / Math.pow(distSq, IDW_POWER / 2);
totalWeight += weight;
weightedSum += node.signedIntensity * weight;
}
if (totalWeight === 0) return 0;
return weightedSum / totalWeight;
};
// -----------------------------
// BUILD POINT CLOUD
// -----------------------------
const { positions, colors } = useMemo(() => {
const positions: number[] = [];
const colors: number[] = [];
signedNodes.forEach((node) => {
const radialSteps = Math.floor(RADIAL_BASE * node.densityMultiplier);
const thetaSteps = Math.floor(THETA_BASE * node.densityMultiplier);
const phiSteps = Math.floor(PHI_BASE * node.densityMultiplier);
for (let rStep = 0; rStep < radialSteps; rStep++) {
const r = Math.sqrt(rStep / radialSteps) * node.cloudRadius;
for (let pStep = 0; pStep < phiSteps; pStep++) {
const phi = ((pStep + 0.5) / phiSteps) * Math.PI;
for (let tStep = 0; tStep < thetaSteps; tStep++) {
const theta = (tStep / thetaSteps) * Math.PI * 2;
const x = node.x + r * Math.sin(phi) * Math.cos(theta);
const y = node.y + r * Math.cos(phi);
const z = node.z + r * Math.sin(phi) * Math.sin(theta);
// Bin boundary checks
if (y < hopperTipY || y > maxGrainY) continue;
const distXZ = Math.sqrt(x * x + z * z);
if (distXZ > maxRadiusAtY(y)) continue;
// Evaluate the full signed field at this point.
// A point in the overlap of a hot and cold cloud will have
// a net value near zero and will be skipped rather than
// rendered pink.
const netField = evaluateField(x, y, z);
if (Math.abs(netField) < EMIT_THRESHOLD) continue;
positions.push(x, y, z);
// Brightness driven by net magnitude, not per-node intensity
const netMagnitude = Math.abs(netField);
const falloff = 1 - r / node.cloudRadius;
const spatialFalloff = Math.pow(Math.max(0, falloff), 2);
const adjustedFalloff = MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff;
const intensityCurve = netMagnitude * netMagnitude;
const brightness = adjustedFalloff * (BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve);
if (netField > 0) {
colors.push(brightness, 0, 0); // hot → red
} else {
colors.push(0, 0, brightness); // cold → blue
}
}
}
}
});
return {
positions: new Float32Array(positions),
colors: new Float32Array(colors),
};
}, [signedNodes, maxGrainY, hopperTipY]);
const circleTexture = useMemo(() => {
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const gradient = ctx.createRadialGradient(
size / 2, size / 2, 0,
size / 2, size / 2, size / 2
);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const texture = new CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}, []);
// -----------------------------
// RENDER
// -----------------------------
return (
<points renderOrder={2}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={positions}
count={positions.length / 3}
itemSize={3}
/>
<bufferAttribute
attach="attributes-color"
array={colors}
count={colors.length / 3}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={POINT_SIZE}
map={circleTexture}
vertexColors
transparent
opacity={OPACITY}
depthWrite={false}
blending={NormalBlending}
/>
</points>
);
}