From 61aa93aabd5a465fff8a54b720557a19450a56c6 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 28 Apr 2026 17:00:09 -0600 Subject: [PATCH] various tweaks and now trying to build heatmaps in two different ways --- src/3dModels/Shapes/BaseMesh.tsx | 12 +- src/bin/3dView/BinParts/BinShell.tsx | 19 +- src/bin/3dView/Data/BuildNodeData.ts | 1 + src/bin/3dView/Scene/Bin3dView.tsx | 21 +- src/bin/3dView/Systems/Cables/BinCables.tsx | 6 +- src/bin/3dView/Systems/Cables/CableNode.tsx | 9 +- src/bin/3dView/Systems/Cables/GrainCable.tsx | 9 +- .../3dView/Systems/Heatmap/HeatMapAlpha.tsx | 634 ++++++++++++------ .../3dView/Systems/Heatmap/NodePointCloud.tsx | 12 +- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 406 +++++++++++ .../Systems/Inventory/GrainCableFill.tsx | 35 +- .../Systems/Inventory/GrainFillFlat.tsx | 12 +- 12 files changed, 932 insertions(+), 244 deletions(-) create mode 100644 src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index d92601d..e06d622 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -16,6 +16,8 @@ export interface BaseMeshProps { materialOverride?: React.ReactNode; side?: Side; depthWrite?: boolean; + depthTest?: boolean; + renderOrder?: number; onClick?: (event: ThreeEvent) => void; } @@ -35,6 +37,8 @@ export default function BaseMesh(props: BaseMeshProps) { materialOverride, side, depthWrite = true, + depthTest = true, + renderOrder = 0, onClick, } = props; @@ -50,6 +54,7 @@ export default function BaseMesh(props: BaseMeshProps) { transparent={isTransparent} opacity={opacity} depthWrite={depthWrite} + depthTest={depthTest} side={side} {...materialProps} /> @@ -64,7 +69,8 @@ export default function BaseMesh(props: BaseMeshProps) { roughness={roughness} transparent={isTransparent} opacity={opacity} - depthWrite={!isTransparent} + depthWrite={depthWrite} + depthTest={depthTest} side={side} {...materialProps} /> @@ -73,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} {buildMaterial()} @@ -81,7 +87,7 @@ export default function BaseMesh(props: BaseMeshProps) { {/* Optional wireframe overlay */} {wireframe && ( - + ({ radiusTop: diameter / 2, radiusBottom: diameter / 2, @@ -86,7 +87,10 @@ export default function BinShell(props: Props) { position={roofPosition} roughness={binRoughness} side={2} - opacity={binOpacity}/> + opacity={binOpacity} + depthWrite={false} + depthTest={false} + renderOrder={renderOrder}/> {/* bin sidewall - cylinder (open ended for the roof and bottom)*/} + opacity={binOpacity} + depthWrite={false} + depthTest={false} + renderOrder={renderOrder}/> {/* bin bottom - cone -OR- circle depending on the bins shape*/} {hopperHeight !== undefined && hopperHeight > 0 ? : } diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index ac8717c..9d7de98 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -44,6 +44,7 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius + // for testing if(i===0){ t = 30 } diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index 403dc8f..a704007 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -8,9 +8,11 @@ import { Vector3 } from "three"; import { useMemo } from "react"; import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; -import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; +import Heatmap from "../Systems/Heatmap/HeatMapAlpha"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; +// import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; interface Props { /** @@ -28,13 +30,17 @@ interface Props { */ fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void + /** + * When true, renders the heatmap instead of the grain fill. + */ + showHeatmap?: boolean } export default function Bin3dView(props: Props){ //this function will generate a 3D model of a bin based on its settings using multiple meshes, cylinder for the body, cone for the roof // and either a cone for the hopper or circle for flat bottom, it is possible to also use lathe geometry for this as well, // it might even work better because we can control the smoothness easier with it being only one mesh rather than 3 - const {bin, scale = 100, fillPercent, nodeClick} = props + const {bin, scale = 100, fillPercent, nodeClick, showHeatmap = false} = props const binCenter = useMemo(() => new Vector3(0, 0, 0), []); const cableData = useMemo(() => BuildCableData(bin), [bin]); const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); @@ -58,6 +64,7 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} hopperHeight={bin.hopperHeight()} fillPercent={fillPercent} + grainOpacity={0.3} /> } } @@ -75,11 +82,11 @@ export default function Bin3dView(props: Props){ sidewallHeight={bin.sidewallHeight()} roofHeight={bin.roofHeight()} hopperHeight={bin.hopperHeight()} - + renderOrder={4} /> {/* grain - cylinder*/} - {grainInventory()} + {/* {!showHeatmap && grainInventory()} */} {/* cables */} { @@ -88,8 +95,10 @@ export default function Bin3dView(props: Props){ if(nodeClick){ nodeClick(node, cable) } - }}/> - + }} + renderOrder={1}/> + {/* */} + {/* lighting */} diff --git a/src/bin/3dView/Systems/Cables/BinCables.tsx b/src/bin/3dView/Systems/Cables/BinCables.tsx index 1c526f3..6adedd1 100644 --- a/src/bin/3dView/Systems/Cables/BinCables.tsx +++ b/src/bin/3dView/Systems/Cables/BinCables.tsx @@ -9,12 +9,13 @@ interface Props { cableData: CableData[] nodeData: NodeData[] bin: Bin - binCenter: Vector3 + binCenter: Vector3, + renderOrder?: number, onNodeClick?: (node: NodeData, cable: CableData) => void } export default function BinCables(props: Props){ - const {bin, binCenter, cableData, nodeData, onNodeClick} = props + const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props return ( {cableData.map((cable, i) => { @@ -32,6 +33,7 @@ export default function BinCables(props: Props){ onNodeClick(node, cable) } }} + renderOrder={renderOrder} /> )} )} diff --git a/src/bin/3dView/Systems/Cables/CableNode.tsx b/src/bin/3dView/Systems/Cables/CableNode.tsx index daf7744..782b40f 100644 --- a/src/bin/3dView/Systems/Cables/CableNode.tsx +++ b/src/bin/3dView/Systems/Cables/CableNode.tsx @@ -15,12 +15,13 @@ interface Props { node: NodeData binCenter: Vector3 lowerThreshold: number - upperThreshold: number + upperThreshold: number, + renderOrder?: number, onNodeClick?: (node: NodeData) => void } export default function CableNode(props: Props) { - const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick } = props + const { node, binCenter, lowerThreshold, upperThreshold, onNodeClick, renderOrder } = props const { camera } = useThree(); const [{ user }] = useGlobalState(); const [showLabel, setShowLabel] = useState(false); @@ -136,9 +137,11 @@ export default function CableNode(props: Props) { position={node.position} colour={nodeColour()} onClick={handleClick} + renderOrder={renderOrder} + /> {node.topNode && ( - + )} } diff --git a/src/bin/3dView/Systems/Cables/GrainCable.tsx b/src/bin/3dView/Systems/Cables/GrainCable.tsx index f8ab324..938dc63 100644 --- a/src/bin/3dView/Systems/Cables/GrainCable.tsx +++ b/src/bin/3dView/Systems/Cables/GrainCable.tsx @@ -10,11 +10,12 @@ interface Props { cable: CableData nodes: NodeData[] bin: Bin - binCenter: Vector3 + binCenter: Vector3, + renderOrder?: number, onNodeClick?: (node: NodeData) => void } export default function GrainCable(props: Props) { - const {cable, nodes, bin, binCenter, onNodeClick} = props + const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props const calculateHeight = () => { return cable.topPosition.distanceTo(cable.bottomPosition) @@ -39,9 +40,9 @@ export default function GrainCable(props: Props) { return ( - + {nodes.map((node, i) => ( - + ))} ) diff --git a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx index 65fa1c1..a67e44b 100644 --- a/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx +++ b/src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx @@ -1,13 +1,71 @@ -import { NodeData } from "bin/3dView/Data/BuildNodeData"; -import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour"; import { Bin } from "models"; -import { pond } from "protobuf-ts/pond"; import { useMemo } from "react"; -import { AdditiveBlending, Vector3 } from "three"; +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 } /** @@ -16,223 +74,379 @@ interface Props{ * @returns */ export default function Heatmap(props: Props){ - const {bin, nodes} = props - //the steps that control how many 'layers' the heatmap will generate for each direction - const radialSteps = 18; // number of points across the diameter of the pin - const heightSteps = 20; // number of points up the side of the bin - const angleSteps = 40; // number of points around the circumfrence of the bin + 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; - const getHeatIntensity = ( - temp: number, - lower: number, - upper: number, - fade: number - ) => { - if (temp >= lower && temp <= upper) return 0; - - const distance = - temp < lower ? lower - temp : temp - upper; - - // clamp 0 → 1 - return Math.min(1, distance / fade); - } + useThree(); // keep fiber context available if needed later - const getTemperatureAtPoint = ( - point: Vector3, - nodes: NodeData[] - ) => { - let totalWeight = 0; - let weightedTemp = 0; - - //not sure if we should use a hard coded value or use a percentage of the bins diameter - //const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points - const maxDistance = bin.diameter() * 0.25; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; - nodes.forEach(node => { - if (!node.inGrain || node.excluded) return; - - const distance = point.distanceTo(node.position); + const inGrainNodes = useMemo( + () => nodes.filter((n) => n.inGrain && !n.excluded), + [nodes], + ); - const intensity = getHeatIntensity( - node.celcius, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - - const nodeMaxDistance = maxDistance * (0.5 + intensity); - // tweakable: 0.5–1.5 range - - if (distance > nodeMaxDistance) return; - - const weight = 1 / (distance * distance + 1); - - totalWeight += weight; - weightedTemp += node.celcius * weight; - }); - - if (totalWeight === 0) return null; - - return weightedTemp / totalWeight; + 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; } - //this gets the highest top node to prevent points from being rendered above it - //we could in the future us the multple top nodes to clamp within a cloumn around that cable which would give us a more realistic grain area - 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]); + return weightedY / totalWeight; + }; - const samplePoints = useMemo(() => { - const points = []; - - const radius = bin.diameter() / 2; - const height = bin.sidewallHeight(); - + 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; + }; - - for (let yStep = 0; yStep < heightSteps; yStep++) { - const y = -height / 2 + (yStep / heightSteps) * height; - if (y > maxGrainY) continue; - - for (let rStep = 0; rStep < radialSteps; rStep++) { - // const r = (rStep / radialSteps) * radius; - const r = Math.sqrt(rStep / radialSteps) * radius; - - for (let aStep = 0; aStep < angleSteps; aStep++) { - const angle = (aStep / angleSteps) * Math.PI * 2; - - const x = Math.cos(angle) * r; - const z = Math.sin(angle) * r; - - const position = new Vector3(x, y, z); - - const temp = getTemperatureAtPoint(position, nodes); - - points.push({ - position, - temp - }); - } + 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 points; - }, [bin, nodes]); + } + } - const { positions, colors, alphas } = useMemo(() => { - const positions: number[] = []; - const colors: number[] = []; - const alphas: number[] = []; - - samplePoints.forEach((p) => { - if (p.temp === null) return; - const intensity = getHeatIntensity( - p.temp, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade - - const virtualNode: NodeData = { - cableIndex: -1, - nodeIndex: -1, - radius: 0, - position: p.position, - celcius: p.temp, - inGrain: true, - excluded: false - }; - - const color = TempToColour( - virtualNode, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - "heatmap" - ); - - if (!color) return; - - // position - positions.push(p.position.x, p.position.y, p.position.z); - - // color (normalized 0–1) - colors.push(color.r, color.g, color.b); - alphas.push(alpha) + 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, + ]); - }); - - return { - positions: new Float32Array(positions), - colors: new Float32Array(colors), - alphas: new Float32Array(alphas) - }; - }, [samplePoints, bin]); + 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; - return ( - - - - - - - - - - ); + 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 ( + + + + + + + + ); } \ No newline at end of file diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx index 75d7944..1af8ba8 100644 --- a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -2,7 +2,7 @@ import { NodeData } from "bin/3dView/Data/BuildNodeData"; import { colourFade } from "bin/3dView/utils/tempToColour"; import { Bin } from "models"; import { useMemo } from "react"; -import { AdditiveBlending, CanvasTexture } from "three"; +import { CanvasTexture, NormalBlending } from "three"; interface Props { bin: Bin; @@ -22,10 +22,10 @@ export default function NodePointCloud(props: Props) { const MIN_EDGE_INTENSITY = 0.2; - const BASE_BRIGHTNESS = 0.2; - const INTENSITY_BRIGHTNESS_MULT = 0.5; + const BASE_BRIGHTNESS = 0.7; + const INTENSITY_BRIGHTNESS_MULT = 0.7; - const OPACITY = 0.7; + const OPACITY = 0.3; const POINT_SIZE = 1; // IDW power — how sharply nearer nodes dominate the field @@ -224,7 +224,7 @@ export default function NodePointCloud(props: Props) { // RENDER // ----------------------------- return ( - + ); diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx new file mode 100644 index 0000000..cbb9d0d --- /dev/null +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -0,0 +1,406 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import { Bin } from "models"; +import { NodeData } from "../../Data/BuildNodeData"; + +interface Props { + bin: Bin; + nodes: NodeData[]; +} + +// ----------------------------------------------------------------------- +// 🎛️ TUNING KNOBS +// ----------------------------------------------------------------------- + +// Grid resolution — more = smoother but heavier +const RADIAL_RINGS = 12; // rings of sample points from center outward +const THETA_SEGMENTS = 24; // points around each ring +const HEIGHT_STEPS = 20; // vertical layers + +// Colour thresholds — degrees °C above the bin's upper threshold +const YELLOW_DELTA = 5; // at this far above threshold → full yellow +const RED_DELTA = 10; // at this far above threshold → full red + +// IDW power — higher = sharper transitions between nodes (2 is standard) +const IDW_POWER = 2; + +// Mesh appearance +const OPACITY = 0.55; + +// ----------------------------------------------------------------------- +// COLOUR HELPERS +// ----------------------------------------------------------------------- + +// Returns 0 (green) → 1 (yellow) → 2 (red) based on how far above +// the upper threshold the interpolated temperature is. +// Everything at or below upper threshold = 0. +function tempToHeat(temp: number, upper: number): number { + if (temp <= upper) return 0; + const delta = temp - upper; + if (delta >= RED_DELTA) return 2; + if (delta >= YELLOW_DELTA) return 1 + (delta - YELLOW_DELTA) / (RED_DELTA - YELLOW_DELTA); + return delta / YELLOW_DELTA; +} + +// Maps heat value [0–2] to RGB. +// 0 = green (#52c41a) +// 1 = yellow (#fadb14) +// 2 = red (#ff4d4f) +function heatToRGB(heat: number): [number, number, number] { + if (heat <= 0) return [0.322, 0.761, 0.102]; // green + + if (heat <= 1) { + // green → yellow + const t = heat; + return [ + 0.322 + (0.980 - 0.322) * t, // R + 0.761 + (0.859 - 0.761) * t, // G + 0.102 + (0.078 - 0.102) * t, // B + ]; + } + + // yellow → red + const t = heat - 1; + return [ + 0.980 + (1.000 - 0.980) * t, // R + 0.859 + (0.302 - 0.859) * t, // G + 0.078 + (0.310 - 0.078) * t, // B + ]; +} + +// ----------------------------------------------------------------------- +// IDW TEMPERATURE INTERPOLATION +// ----------------------------------------------------------------------- + +interface TempAnchor { + x: number; y: number; z: number; + celcius: number; +} + +function idwTemp( + px: number, py: number, pz: number, + anchors: TempAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedSum = 0; + + for (const a of anchors) { + const dx = px - a.x; + const dy = py - a.y; + const dz = pz - a.z; + const distSq = dx * dx + dy * dy + dz * dz; + + if (distSq < 0.001) return a.celcius; // exactly on a node + + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedSum += a.celcius * weight; + } + + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} + +// ----------------------------------------------------------------------- +// COMPONENT +// ----------------------------------------------------------------------- + +export default function TempHeatMap(props: Props) { + const { bin, nodes } = props; + + const binRadius = bin.diameter() / 2; + const sidewallHeight = bin.sidewallHeight(); + const hopperHeight = bin.hopperHeight() ?? 0; + const upperThreshold = bin.upperTempThreshold(); + const sidewallBaseY = -sidewallHeight / 2; + const hopperTipY = sidewallBaseY - hopperHeight; + + // Taper radius inside the hopper cone + const maxRadiusAtY = (y: number): number => { + if (y >= sidewallBaseY) return binRadius; + if (hopperHeight <= 0 || y <= hopperTipY) return 0; + return binRadius * ((y - hopperTipY) / hopperHeight); + }; + + // Only use in-grain, non-excluded nodes as temperature anchors + const anchors = useMemo(() => + nodes + .filter(n => n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + y: n.position.y, + z: n.position.z, + celcius: n.celcius, + })), + [nodes] + ); + + // Top of grain — highest in-grain node Y + const maxGrainY = useMemo(() => { + let maxY = sidewallBaseY; + for (const a of anchors) { + if (a.y > maxY) maxY = a.y; + } + return maxY; + }, [anchors, sidewallBaseY]); + + // ----------------------------------------------------------------------- + // BUILD GEOMETRY + // ----------------------------------------------------------------------- + const geometry = useMemo(() => { + if (anchors.length === 0) return null; + + // ------------------------------------------------------------------- + // 1. Sample the cylindrical grid + // ------------------------------------------------------------------- + // Layout: center column + RADIAL_RINGS rings, each with THETA_SEGMENTS + // vertices, stacked HEIGHT_STEPS times vertically. + // + // Vertex index scheme: + // layer * pointsPerLayer + ringOffset + // where ringOffset: 0 = center, 1..N = ring vertices + + const pointsPerLayer = 1 + RADIAL_RINGS * THETA_SEGMENTS; + // +1 for the optional hopper tip vertex (unused for flat-bottom bins) + const totalVerts = HEIGHT_STEPS * pointsPerLayer + 1; + + const positions = new Float32Array(totalVerts * 3); + const colors = new Float32Array(totalVerts * 3); + + // Grain bottom Y — bottom of the grain, either hopper tip or sidewall base + // For hopper bins, starting exactly at hopperTipY causes the entire + // bottom layer to collapse to radius=0 (degenerate triangles that + // disappear when viewed from below). Instead start one HEIGHT_STEPS + // increment above the tip so the bottom layer always has a visible + // radius, then add a separate tip vertex that fans down to a point. + const rawBottomY = hopperHeight > 0 ? hopperTipY : sidewallBaseY; + const grainBottomY = hopperHeight > 0 + ? hopperTipY + (maxGrainY - hopperTipY) / HEIGHT_STEPS + : sidewallBaseY; + const grainHeight = maxGrainY - grainBottomY; + + if (grainHeight <= 0) return null; + + for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) { + const t = hStep / (HEIGHT_STEPS - 1); + const y = grainBottomY + t * grainHeight; + + const allowedRadius = maxRadiusAtY(y) * 0.97; // slight inset + const layerBase = hStep * pointsPerLayer; + + // Center vertex + const cx = 0, cz = 0; + const centerTemp = idwTemp(cx, y, cz, anchors, IDW_POWER); + const centerHeat = tempToHeat(centerTemp, upperThreshold); + const [cr, cg, cb] = heatToRGB(centerHeat); + + positions[layerBase * 3 + 0] = cx; + positions[layerBase * 3 + 1] = y; + positions[layerBase * 3 + 2] = cz; + colors[layerBase * 3 + 0] = cr; + colors[layerBase * 3 + 1] = cg; + colors[layerBase * 3 + 2] = cb; + + // Ring vertices + for (let ring = 0; ring < RADIAL_RINGS; ring++) { + // sqrt distribution keeps area density even across rings + const rFrac = Math.sqrt((ring + 1) / RADIAL_RINGS); + const r = rFrac * allowedRadius; + + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const angle = (seg / THETA_SEGMENTS) * Math.PI * 2; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + + const temp = idwTemp(x, y, z, anchors, IDW_POWER); + const heat = tempToHeat(temp, upperThreshold); + const [vr, vg, vb] = heatToRGB(heat); + + const vi = layerBase + 1 + ring * THETA_SEGMENTS + seg; + positions[vi * 3 + 0] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + colors[vi * 3 + 0] = vr; + colors[vi * 3 + 1] = vg; + colors[vi * 3 + 2] = vb; + } + } + } + + // ------------------------------------------------------------------- + // 2. Build triangle indices + // ------------------------------------------------------------------- + // For each pair of adjacent height layers, connect: + // (a) center fan for the innermost ring + // (b) quad strips between adjacent rings + // (c) quad strips between outermost ring top/bottom caps + // We also cap the top and bottom with fans. + + const indices: number[] = []; + + const idx = (hStep: number, ring: number, seg: number): number => { + // ring -1 = center vertex + if (ring < 0) return hStep * pointsPerLayer; + const s = ((seg % THETA_SEGMENTS) + THETA_SEGMENTS) % THETA_SEGMENTS; + return hStep * pointsPerLayer + 1 + ring * THETA_SEGMENTS + s; + }; + + // Side walls — connect each layer to the next + for (let h = 0; h < HEIGHT_STEPS - 1; h++) { + // Center → first ring quads (actually triangles since one side is a point) + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + // tri: center(h), ring0(h,seg), ring0(h,next) + indices.push(idx(h, -1, 0), idx(h, 0, seg), idx(h, 0, next)); + // tri: center(h+1), ring0(h+1,next), ring0(h+1,seg) + indices.push(idx(h + 1, -1, 0), idx(h + 1, 0, next), idx(h + 1, 0, seg)); + // quad connecting the two center fans + indices.push( + idx(h, -1, 0), idx(h + 1, -1, 0), idx(h, 0, seg), + ); + indices.push( + idx(h + 1, -1, 0), idx(h + 1, 0, seg), idx(h, 0, seg), + ); + } + + // Ring-to-ring quads + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + + // quad between ring and ring+1 at layer h + const a = idx(h, ring, seg); + const b = idx(h, ring, next); + const c = idx(h, ring + 1, seg); + const d = idx(h, ring + 1, next); + + // quad between ring and ring+1 at layer h+1 + const e = idx(h + 1, ring, seg); + const f = idx(h + 1, ring, next); + const g = idx(h + 1, ring + 1, seg); + const hh = idx(h + 1, ring + 1, next); + + // side face (h → h+1 for this quad) + indices.push(a, e, b); + indices.push(e, f, b); + + // inner ring cap face at layer h + indices.push(a, b, c); + indices.push(b, d, c); + + // inner ring cap face at layer h+1 + indices.push(e, g, f); + indices.push(f, g, hh); + } + } + + // Outermost ring side faces + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(h, outerRing, seg); + const b = idx(h, outerRing, next); + const c = idx(h + 1, outerRing, seg); + const d = idx(h + 1, outerRing, next); + indices.push(a, c, b); + indices.push(b, c, d); + } + } + + // Bottom cap — fan from center to outermost ring + const hBottom = 0; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + indices.push( + idx(hBottom, -1, 0), + idx(hBottom, RADIAL_RINGS - 1, next), + idx(hBottom, RADIAL_RINGS - 1, seg), + ); + } + + // Top cap + const hTop = HEIGHT_STEPS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + indices.push( + idx(hTop, -1, 0), + idx(hTop, RADIAL_RINGS - 1, seg), + idx(hTop, RADIAL_RINGS - 1, next), + ); + } + + // ------------------------------------------------------------------- + // 2b. Hopper tip vertex + fan (only for hopper bins) + // ------------------------------------------------------------------- + // The tip vertex sits at the very last slot in the buffer. + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + + if (hopperHeight > 0) { + const tipTemp = idwTemp(0, rawBottomY, 0, anchors, IDW_POWER); + const tipHeat = tempToHeat(tipTemp, upperThreshold); + const [tr, tg, tb] = heatToRGB(tipHeat); + + positions[tipVertexIndex * 3 + 0] = 0; + positions[tipVertexIndex * 3 + 1] = rawBottomY; // hopperTipY + positions[tipVertexIndex * 3 + 2] = 0; + colors[tipVertexIndex * 3 + 0] = tr; + colors[tipVertexIndex * 3 + 1] = tg; + colors[tipVertexIndex * 3 + 2] = tb; + + // Fan from bottom layer's outermost ring down to the tip point. + // This fills the gap between grainBottomY and hopperTipY. + const hBottom = 0; + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(hBottom, outerRing, seg); + const b = idx(hBottom, outerRing, next); + // Wind so the face is visible from below (tip → b → a) + indices.push(tipVertexIndex, b, a); + } + + // Also fan the bottom layer rings down to the tip for the + // interior of the hopper cone + for (let ring = 0; ring < RADIAL_RINGS - 1; ring++) { + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + const a = idx(hBottom, ring, seg); + const b = idx(hBottom, ring, next); + indices.push(tipVertexIndex, b, a); + } + } + // Center to tip + indices.push(tipVertexIndex, idx(hBottom, -1, 0), idx(hBottom, 0, 0)); + } + + // ------------------------------------------------------------------- + // 3. Assemble BufferGeometry + // ------------------------------------------------------------------- + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geo.setIndex(indices); + return geo; + }, [anchors, maxGrainY, hopperTipY, sidewallBaseY, binRadius, upperThreshold, hopperHeight]); + + if (!geometry) return null; + + // meshBasicMaterial is used intentionally here instead of meshStandardMaterial: + // - No lighting/normal calculations means face winding direction does not affect + // visibility, so the mesh looks identical from all camera angles including + // below and inside the volume. + // - vertexColors drives all colour — lighting would wash out the green/yellow/red + // gradient anyway depending on light angle. + return ( + + + + ); +} diff --git a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx index 0ae8a2e..3cb8cd7 100644 --- a/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainCableFill.tsx @@ -260,13 +260,30 @@ export default function GrainCableFill(props: Props) { roughness={1} metalness={0} opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} {cylinderFillHeight > 0 && ( - - - - + )} ); @@ -275,9 +292,9 @@ export default function GrainCableFill(props: Props) { // --- Render cable-driven surface --- return ( <> - {/* Interpolated grain surface */} + {/* Interpolated grain surface - i have not made a react component for this shape, not exactly a basic shape, so am just using mesh as is */} {surfaceGeometry && ( - + )} @@ -305,6 +323,8 @@ export default function GrainCableFill(props: Props) { roughness={1} opacity={grainOpacity} depthWrite={false} + depthTest={false} + renderOrder={0} /> {/* Hopper fill — always full when grain surface exists above the floor */} @@ -322,6 +342,9 @@ export default function GrainCableFill(props: Props) { roughness={1} metalness={0} opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} diff --git a/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx index 29e0db3..e954a1d 100644 --- a/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx +++ b/src/bin/3dView/Systems/Inventory/GrainFillFlat.tsx @@ -8,6 +8,7 @@ interface Props { sidewallHeight: number; hopperHeight?: number; fillPercent: number; + grainOpacity?: number; } export default function GrainFillFlat(props: Props) { @@ -15,7 +16,8 @@ export default function GrainFillFlat(props: Props) { diameter, sidewallHeight, hopperHeight = 0, - fillPercent + fillPercent, + grainOpacity } = props; const radius = diameter / 2; @@ -99,6 +101,10 @@ export default function GrainFillFlat(props: Props) { colour={grainColour} roughness={1} metalness={0} + opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )} @@ -116,6 +122,10 @@ export default function GrainFillFlat(props: Props) { colour={grainColour} roughness={1} metalness={0} + opacity={grainOpacity} + depthWrite={false} + depthTest={false} + renderOrder={0} /> )}