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 ( ); }