diff --git a/src/bin/3dView/Data/BuildNodeData.ts b/src/bin/3dView/Data/BuildNodeData.ts index 4196820..ac8717c 100644 --- a/src/bin/3dView/Data/BuildNodeData.ts +++ b/src/bin/3dView/Data/BuildNodeData.ts @@ -44,6 +44,12 @@ export function BuildNodeData(cables: CableData[]): NodeData[] { const humidity = grainCable.relativeHumidity[i] || undefined; const moisture = grainCable.moisture[i] || undefined; let t = celcius + if(i===0){ + t = 30 + } + if (i===1){ + t = 0 + } nodeData.push({ cableIndex: cableIndex, diff --git a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx index 65768de..75d7944 100644 --- a/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx +++ b/src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx @@ -15,31 +15,40 @@ export default function NodePointCloud(props: Props) { 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.2; const INTENSITY_BRIGHTNESS_MULT = 0.5; - + const OPACITY = 0.7; const POINT_SIZE = 1; - const getHeatIntensity = ( - temp: number, - lower: number, - upper: number, - fade: number - ) => { - if (temp >= lower && temp <= upper) return 0; + // IDW power — how sharply nearer nodes dominate the field + const IDW_POWER = 2; - const distance = - temp < lower ? lower - temp : temp - upper; + // Minimum net signed magnitude to emit a point. + // Keeps near-zero cancellation zones empty rather than noisy. + const EMIT_THRESHOLD = 0.05; - return Math.min(1, distance / fade); + // ----------------------------- + // 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; }; // ----------------------------- @@ -47,101 +56,135 @@ export default function NodePointCloud(props: Props) { // ----------------------------- 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]); // ----------------------------- - // BUILD POINT CLOUD FROM 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[] = []; - const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR; + 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); - nodes.forEach((node) => { - if (!node.inGrain || node.excluded) return; - - const intensity = getHeatIntensity( - node.celcius, - bin.lowerTempThreshold(), - bin.upperTempThreshold(), - colourFade - ); - - // skip normal nodes - if (intensity === 0) return; - - const isHot = node.celcius > bin.upperTempThreshold(); - - // expand cloud based on severity - const radius = baseRadius * (0.5 + intensity); - - //these control the number of points along that axis - const densityMultiplier = INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT; - const radialSteps = Math.floor(RADIAL_BASE * densityMultiplier);//points along the radius to the edge - const thetaSteps = Math.floor(THETA_BASE * densityMultiplier);//points along the azimuth (horizontal angle) - const phiSteps = Math.floor(PHI_BASE * densityMultiplier);//points along the vertical angle - - const radiusLimit = bin.diameter() / 2; - const minY = -bin.sidewallHeight() / 2; - for (let rStep = 0; rStep < radialSteps; rStep++) { - // sqrt for even density - const r = Math.sqrt(rStep / radialSteps) * radius; - + const r = Math.sqrt(rStep / radialSteps) * node.cloudRadius; + for (let pStep = 0; pStep < phiSteps; pStep++) { - // avoid poles clustering by not hitting exact 0/PI - const phi = - ((pStep + 0.5) / phiSteps) * Math.PI; - + 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.position.x + - r * Math.sin(phi) * Math.cos(theta); - - const y = - node.position.y + - r * Math.cos(phi); - - const z = - node.position.z + - r * Math.sin(phi) * Math.sin(theta); - - // cylindrical bin bounds + 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 > radiusLimit) continue; - if (y > maxGrainY || y < minY) continue; - + 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); - const falloff = 1 - r / radius; + // 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); - // shape of the curve - const spatialFalloff = Math.pow(falloff, 2); - - const adjustedFalloff = - MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff; - - const intensityCurve = intensity * intensity; // quadratic easing - const baseBrightness = - BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve; - - const brightness = adjustedFalloff * baseBrightness; - - if (isHot) { - colors.push(brightness, 0, 0); + if (netField > 0) { + colors.push(brightness, 0, 0); // hot → red } else { - colors.push(0, 0, brightness); + colors.push(0, 0, brightness); // cold → blue } } } @@ -152,30 +195,26 @@ export default function NodePointCloud(props: Props) { positions: new Float32Array(positions), colors: new Float32Array(colors), }; - }, [nodes, bin, maxGrainY]); + }, [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 + 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;