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

452 lines
No EOL
14 KiB
TypeScript

import { Bin } from "models";
import { useMemo } from "react";
import {
Color,
ShaderMaterial,
} from "three";
import { useThree } from "@react-three/fiber";
import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { colourFade } from "bin/3dView/utils/tempToColour";
interface Props{
bin: Bin
nodes: NodeData[]
opacity?: number
/**
* Point opacity (lower = see deeper).
*/
pointOpacity?: number
/**
* Point size in world units (scaled with your Bin3dView scale group).
*/
pointSize?: number
/**
* Enables MSAA alpha coverage smoothing (WebGL2 + MSAA).
* Helps look continuous without additive blending.
*/
alphaToCoverage?: boolean
/**
* Density along Y (vertical slices).
*/
ySlices?: number
/**
* Radial rings per slice.
*/
radialRings?: number
/**
* Angular segments per ring.
*/
thetaSegments?: number
/**
* Inset to avoid z-fighting with the shell.
*/
wallInsetFactor?: number
/**
* Adds jittered samples inside each polar cell to better fill the volume.
*/
samplesPerCell?: number
/**
* 0..1 jitter amount within a cell (0 = none).
*/
jitter?: number
/**
* Enables screen-door alpha hashing. This fixes incorrect transparency sorting
* (points popping in front when tilted) without additive blending.
*/
alphaHash?: boolean
/**
* Makes in-threshold (green) points more transparent so hot/cold pockets show through.
* 0..1 where 0 = invisible green, 1 = same opacity as out-of-threshold.
*/
greenOpacityFactor?: number
/**
* Curves how strongly out-of-threshold points become visible.
* >1 makes only strong deviations pop; <1 makes small deviations pop more.
*/
deviationPower?: number
// (reverted) extra perf knobs removed
}
/**
* this is a work in progress, the heatmap generated may not be accurate so avoid using this component for now
* @param props
* @returns
*/
export default function Heatmap(props: Props){
const {
bin,
nodes,
opacity = 0.65, // kept for backward compatibility
pointOpacity,
pointSize = 5,
ySlices = 22,
radialRings = 16,
thetaSegments = 28,
wallInsetFactor = 0.99,
samplesPerCell = 1,
jitter = 0.75,
alphaHash = true,
greenOpacityFactor = 0.18,
deviationPower = 0.6,
} = props;
useThree(); // keep fiber context available if needed later
const sidewallHeight = bin.sidewallHeight();
const hopperHeight = bin.hopperHeight() ?? 0;
const sidewallBaseY = -sidewallHeight / 2;
const hopperTipY = sidewallBaseY - hopperHeight;
const inGrainNodes = useMemo(
() => nodes.filter((n) => n.inGrain && !n.excluded),
[nodes],
);
const maxGrainY = useMemo(() => {
let maxY = -Infinity;
for (const n of inGrainNodes) maxY = Math.max(maxY, n.position.y);
return Number.isFinite(maxY) ? maxY : sidewallBaseY;
}, [inGrainNodes, sidewallBaseY]);
const topNodes = useMemo(
() => nodes.filter((n) => n.topNode && n.inGrain && !n.excluded),
[nodes],
);
const anchors = useMemo(
() =>
topNodes.map((n) => ({
x: n.position.x,
z: n.position.z,
y: n.position.y + n.nodeSpacing * 0.5,
})),
[topNodes],
);
const wallY = useMemo(() => {
if (anchors.length === 0) return -sidewallHeight / 2;
return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length;
}, [anchors, sidewallHeight]);
const idwHeight = (
x: number,
z: number,
inputAnchors: { x: number; z: number; y: number }[],
power = 2,
): number => {
let totalWeight = 0;
let weightedY = 0;
for (const a of inputAnchors) {
const dx = x - a.x;
const dz = z - a.z;
const distSq = dx * dx + dz * dz;
if (distSq < 0.001) return a.y;
const w = 1 / Math.pow(distSq, power / 2);
totalWeight += w;
weightedY += a.y * w;
}
return weightedY / totalWeight;
};
const maxRadiusAtY = (y: number, maxR: number): number => {
if (y >= sidewallBaseY) return maxR;
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
const t = (y - hopperTipY) / hopperHeight; // 0..1
return maxR * t;
};
const grainSurfaceY = (x: number, z: number, rNorm: number): number => {
// If we don't have top nodes, use a flat surface at maxGrainY.
if (anchors.length === 0) return maxGrainY;
const rawY = idwHeight(x, z, anchors);
// Match `GrainCableFill` outer-wall taper so switching isn't jarring.
const edgeStart = 0.8;
const blendT = Math.max(0, (rNorm - edgeStart) / (1 - edgeStart));
const s = blendT * blendT * (3 - 2 * blendT);
const y = rawY * (1 - s) + wallY * s;
return Math.max(-sidewallHeight / 2, Math.min(sidewallHeight / 2, y));
};
const evaluateTemp = (px: number, py: number, pz: number): number | null => {
if (inGrainNodes.length === 0) return null;
// Inverse-distance weighted interpolation.
// Keep power modest so the field stays smooth.
const IDW_POWER = 2;
let totalWeight = 0;
let weightedSum = 0;
for (const n of inGrainNodes) {
const dx = px - n.position.x;
const dy = py - n.position.y;
const dz = pz - n.position.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 += n.celcius * weight;
}
if (totalWeight === 0) return null;
return weightedSum / totalWeight;
};
const tempToHeatColor = (temp: number): Color => {
// Match your 2D/point visuals: green in-threshold, fade to red/blue as distance grows.
const lower = bin.lowerTempThreshold();
const upper = bin.upperTempThreshold();
const GREEN = new Color("#52c41a");
const BLUE = new Color("#3399ff");
const RED = new Color("#ff4d4f");
if (temp >= lower && temp <= upper) return GREEN;
const distance = temp < lower ? lower - temp : temp - upper;
const intensity = Math.min(1, distance / colourFade); // 0..1
// Similar HSL shaping as `TempToColour`, but always returns a color.
const minimumLightness = 0.3;
const lightnessRange = 0.2;
const minimumSaturation = 0.7;
const saturationRange = 0.8;
const hsl = { h: 0, s: 1, l: 1 };
(temp < lower ? BLUE : RED).getHSL(hsl);
const c = new Color();
c.setHSL(
hsl.h,
saturationRange * intensity + minimumSaturation,
lightnessRange * intensity + minimumLightness,
);
return c;
};
const tempToDeviation = (temp: number): number => {
const lower = bin.lowerTempThreshold();
const upper = bin.upperTempThreshold();
if (temp >= lower && temp <= upper) return 0;
const distance = temp < lower ? lower - temp : temp - upper;
return Math.min(1, distance / colourFade);
};
const { positions, colors, deviations } = useMemo(() => {
const binR = bin.diameter() / 2;
// Important: points are rendered as *sprites*, so even if the center is inside the wall,
// the visible circle can extend outside. Shrink the sampling radius by ~half pointSize
// so the rendered splats stay within the bin.
const maxR = Math.max(0, binR * wallInsetFactor - pointSize * 0.55);
const y0 = hopperHeight > 0 ? hopperTipY : sidewallBaseY;
const y1 = Math.max(y0, maxGrainY);
const pos: number[] = [];
const col: number[] = [];
const dev: number[] = [];
const tmpColor = new Color();
const safeYSlices = Math.max(6, Math.floor(ySlices));
const safeRings = Math.max(4, Math.floor(radialRings));
const safeTheta = Math.max(12, Math.floor(thetaSegments));
const safeSamples = Math.max(1, Math.floor(samplesPerCell));
const j = Math.min(1, Math.max(0, jitter));
// Deterministic "random" so the cloud doesn't shimmer every render.
const rand01 = (seed: number) => {
// xorshift32
let x = seed | 0;
x ^= x << 13;
x ^= x >>> 17;
x ^= x << 5;
// convert to [0,1)
return ((x >>> 0) % 1000000) / 1000000;
};
for (let yi = 0; yi < safeYSlices; yi++) {
const ty = safeYSlices === 1 ? 0 : yi / (safeYSlices - 1);
const y = y0 + (y1 - y0) * ty;
const rAtY = maxRadiusAtY(y, maxR);
if (rAtY <= 0.001) continue;
for (let ring = 0; ring < safeRings; ring++) {
for (let seg = 0; seg < safeTheta; seg++) {
// Cell bounds in polar space
const ring0 = ring / safeRings;
const ring1 = (ring + 1) / safeRings;
const r0 = Math.sqrt(ring0) * rAtY;
const r1 = Math.sqrt(ring1) * rAtY;
const theta0 = (seg / safeTheta) * Math.PI * 2;
const theta1 = ((seg + 1) / safeTheta) * Math.PI * 2;
for (let s = 0; s < safeSamples; s++) {
const seed = yi * 73856093 + ring * 19349663 + seg * 83492791 + s * 2654435761;
const u = rand01(seed);
const v = rand01(seed ^ 0x9e3779b9);
// Jitter inside the cell
const rr = r0 + (r1 - r0) * (j === 0 ? 0.5 : (0.5 + (u - 0.5) * j));
const tt = theta0 + (theta1 - theta0) * (j === 0 ? 0.5 : (0.5 + (v - 0.5) * j));
const x = Math.cos(tt) * rr;
const z = Math.sin(tt) * rr;
const rNorm = rAtY <= 0 ? 0 : rr / rAtY;
const surfaceY = grainSurfaceY(x, z, rNorm);
if (y > surfaceY) continue;
const temp = evaluateTemp(x, y, z);
const d0 = temp == null ? 0 : tempToDeviation(temp);
pos.push(x, y, z);
const c = temp == null ? tmpColor.set("#52c41a") : tempToHeatColor(temp);
col.push(c.r, c.g, c.b);
dev.push(d0);
}
}
}
}
return {
positions: new Float32Array(pos),
colors: new Float32Array(col),
deviations: new Float32Array(dev),
};
}, [
bin,
wallInsetFactor,
hopperHeight,
hopperTipY,
sidewallBaseY,
maxGrainY,
ySlices,
radialRings,
thetaSegments,
anchors,
wallY,
inGrainNodes,
samplesPerCell,
jitter,
deviationPower,
]);
const alphaHashedMaterial = useMemo(() => {
return new ShaderMaterial({
transparent: !alphaHash,
depthTest: true,
depthWrite: alphaHash,
uniforms: {
uOpacity: { value: pointOpacity ?? opacity },
uSize: { value: pointSize },
uMaxRadius: { value: (bin.diameter() / 2) * wallInsetFactor },
uSidewallBaseY: { value: -bin.sidewallHeight() / 2 },
uHopperHeight: { value: bin.hopperHeight() ?? 0 },
uAlphaHash: { value: alphaHash ? 1 : 0 },
uGreenOpacityFactor: { value: Math.min(1, Math.max(0, greenOpacityFactor)) },
uDeviationPower: { value: Math.max(0.05, deviationPower) },
},
vertexShader: `
uniform float uSize;
varying vec3 vWorldPos;
varying vec3 vColor;
varying float vDev;
attribute vec3 color;
attribute float deviation;
void main() {
vColor = color;
vDev = deviation;
vec4 world = modelMatrix * vec4(position, 1.0);
vWorldPos = world.xyz;
vec4 mvPosition = viewMatrix * world;
float attn = 300.0 / max(1.0, -mvPosition.z);
gl_PointSize = uSize * attn;
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
precision highp float;
uniform float uOpacity;
uniform float uMaxRadius;
uniform float uSidewallBaseY;
uniform float uHopperHeight;
uniform float uAlphaHash;
uniform float uGreenOpacityFactor;
uniform float uDeviationPower;
varying vec3 vColor;
varying float vDev;
varying vec3 vWorldPos;
// interleaved gradient noise
float ign(vec2 p) {
return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
}
void main() {
// Hard clip pixels to bin radius at this Y (prevents splats outside wall).
float y = vWorldPos.y;
float sidewallBaseY = uSidewallBaseY;
float hopperHeight = uHopperHeight;
float hopperTipY = sidewallBaseY - hopperHeight;
float maxR;
if (y >= sidewallBaseY) {
maxR = uMaxRadius;
} else if (hopperHeight <= 0.0 || y <= hopperTipY) {
maxR = 0.0;
} else {
float t = (y - hopperTipY) / hopperHeight;
maxR = uMaxRadius * t;
}
float r = length(vWorldPos.xz);
if (r > maxR) discard;
vec2 p = gl_PointCoord - vec2(0.5);
float d = length(p) * 2.0;
float mask = smoothstep(1.0, 0.0, d);
float dev = clamp(vDev, 0.0, 1.0);
float devCurve = pow(dev, uDeviationPower);
// 0 => green/in-threshold, 1 => strong deviation
float localOpacityFactor = mix(uGreenOpacityFactor, 1.0, devCurve);
float a = clamp(mask * uOpacity * localOpacityFactor, 0.0, 1.0);
if (uAlphaHash > 0.5) {
float n = ign(gl_FragCoord.xy);
if (n > a) discard;
gl_FragColor = vec4(vColor, 1.0);
} else {
gl_FragColor = vec4(vColor, a);
}
}
`,
});
}, [alphaHash, bin, deviationPower, greenOpacityFactor, opacity, pointOpacity, pointSize, wallInsetFactor]);
// Fallback: normal points (no OIT)
return (
<points renderOrder={2} material={alphaHashedMaterial ?? undefined}>
<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}
/>
<bufferAttribute
attach="attributes-deviation"
array={deviations}
count={deviations.length}
itemSize={1}
/>
</bufferGeometry>
</points>
);
}