moved from a general heatmap to a more spatial hot cold point around nodes that the density and brightness are controlled by how far past the threshold the nodes temp is

This commit is contained in:
csawatzky 2026-04-21 12:24:04 -06:00
parent 6c87ed03ed
commit 6cc40e159a
9 changed files with 493 additions and 26 deletions

View file

@ -14,6 +14,7 @@ export interface BaseMeshProps {
materialProps?: Partial<MaterialParameters>; materialProps?: Partial<MaterialParameters>;
materialOverride?: React.ReactNode; materialOverride?: React.ReactNode;
side?: Side side?: Side
depthWrite?: boolean
} }
export default function BaseMesh(props: BaseMeshProps) { export default function BaseMesh(props: BaseMeshProps) {
@ -30,7 +31,8 @@ export default function BaseMesh(props: BaseMeshProps) {
materialType, materialType,
materialProps, materialProps,
materialOverride, materialOverride,
side side,
depthWrite = true
} = props; } = props;
const isTransparent = opacity < 1; const isTransparent = opacity < 1;
@ -43,7 +45,7 @@ export default function BaseMesh(props: BaseMeshProps) {
color={colour} color={colour}
transparent={isTransparent} transparent={isTransparent}
opacity={opacity} opacity={opacity}
depthWrite={!isTransparent} depthWrite={depthWrite}
side={side} side={side}
{...materialProps} {...materialProps}
/> />

View file

@ -33,17 +33,28 @@ export function BuildNodeData(cables: CableData[]): NodeData[] {
grainCable.celcius.forEach((celcius,i) => { grainCable.celcius.forEach((celcius,i) => {
const nodeY = bottomY + (i * spacing); const nodeY = bottomY + (i * spacing);
const humidity = grainCable.relativeHumidity[i] || undefined; const humidity = grainCable.relativeHumidity[i] || undefined;
const moisture = grainCable.moisture[i] || undefined; const moisture = grainCable.moisture[i] || undefined;
let t = celcius
//this is for testing to force a single node to a specific temp
// if (i === 0) {
// t = 30
// }
// if (i === 1) {
// t = -30
// }
nodeData.push({ nodeData.push({
cableIndex: cableIndex, cableIndex: cableIndex,
nodeIndex: i, nodeIndex: i,
radius: nodeRadius, radius: nodeRadius,
position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z), position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z),
celcius: celcius, celcius: t,
humidity: humidity, humidity: humidity,
moisture: moisture, moisture: moisture,
topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index

View file

@ -8,6 +8,8 @@ import { Vector3 } from "three";
import { useMemo } from "react"; import { useMemo } from "react";
import { BuildCableData } from "../Data/BuildCableData"; import { BuildCableData } from "../Data/BuildCableData";
import { BuildNodeData } from "../Data/BuildNodeData"; import { BuildNodeData } from "../Data/BuildNodeData";
import Heatmap from "../Systems/Heatmap/HeatMapAlpha";
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
interface Props { interface Props {
/** /**
@ -43,7 +45,7 @@ export default function Bin3dView(props: Props){
radialSegments={20} radialSegments={20}
binMetalness={0.5} binMetalness={0.5}
binRoughness={0.7} binRoughness={0.7}
binOpacity={0.5} binOpacity={0.2}
diameter={bin.diameter()} diameter={bin.diameter()}
sidewallHeight={bin.sidewallHeight()} sidewallHeight={bin.sidewallHeight()}
roofHeight={bin.roofHeight()} roofHeight={bin.roofHeight()}
@ -62,6 +64,7 @@ export default function Bin3dView(props: Props){
)} )}
{/* cables */} {/* cables */}
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/> <BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/>
<NodePointCloud bin={bin} nodes={nodeData} />
</group> </group>
{/* lighting */} {/* lighting */}

View file

@ -10,7 +10,7 @@ import { pond } from "protobuf-ts/pond"
import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring" import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring"
// import { Color } from "three"; // import { Color } from "three";
import { NodeData } from "../../Data/BuildNodeData" import { NodeData } from "../../Data/BuildNodeData"
import { tempToColour } from "bin/3dView/utils/tempToColour" import { TempToColour } from "bin/3dView/utils/tempToColour"
interface Props { interface Props {
node: NodeData node: NodeData
@ -193,12 +193,26 @@ export default function CableNode(props: Props){
return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2; return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2;
}; };
const nodeColour = () => {
if (node.excluded){
return "grey"
}
if (!node.inGrain){
return "white"
}
let c = TempToColour(node, lowerThreshold, upperThreshold, "node")
if (c !== null){
return c.getStyle()
}
return "white"
}
return ( return (
<React.Fragment> <React.Fragment>
{(!node.inGrain || !showLabel) && {(!node.inGrain || !showLabel) &&
<React.Fragment> <React.Fragment>
<Sphere geometry={geometry} position={node.position} colour={tempToColour(node, lowerThreshold, upperThreshold, "node")?.getStyle()}/> <Sphere geometry={geometry} position={node.position} colour={nodeColour()}/>
{node.topNode && ( {node.topNode && (
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation}/> <Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation}/>
)} )}

View file

@ -0,0 +1,238 @@
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";
interface Props{
bin: Bin
nodes: NodeData[]
}
/**
* 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} = 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 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);
}
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;
nodes.forEach(node => {
if (!node.inGrain || node.excluded) return;
const distance = point.distanceTo(node.position);
const intensity = getHeatIntensity(
node.celcius,
bin.lowerTempThreshold(),
bin.upperTempThreshold(),
colourFade
);
const nodeMaxDistance = maxDistance * (0.5 + intensity);
// tweakable: 0.51.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;
}
//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]);
const samplePoints = useMemo(() => {
const points = [];
const radius = bin.diameter() / 2;
const height = bin.sidewallHeight();
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
});
}
}
}
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 01)
colors.push(color.r, color.g, color.b);
alphas.push(alpha)
});
return {
positions: new Float32Array(positions),
colors: new Float32Array(colors),
alphas: new Float32Array(alphas)
};
}, [samplePoints, bin]);
return (
<points>
<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-alpha"
array={alphas}
itemSize={1}
/>
</bufferGeometry>
<shaderMaterial
transparent
depthWrite={false}
blending={AdditiveBlending}
vertexColors
uniforms={{ pointSize: { value: 0.7 } }}
vertexShader={`
attribute float alpha;
varying float vAlpha;
varying vec3 vColor;
void main() {
vAlpha = alpha;
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 10.0; // tweak this instead of size prop
gl_Position = projectionMatrix * mvPosition;
}
`}
fragmentShader={`
varying float vAlpha;
varying vec3 vColor;
void main() {
float dist = length(gl_PointCoord - vec2(0.5));
float falloff = smoothstep(0.5, 0.0, dist);
gl_FragColor = vec4(vColor, vAlpha * falloff);
}
`}
/>
</points>
);
}

View file

@ -1,16 +0,0 @@
import { NodeData } from "bin/3dView/Data/BuildNodeData";
import { Bin } from "models";
interface Props{
bin: Bin
nodes: NodeData
}
export default function Heatmap(props: Props){
const {bin, nodes} = props
return (
<>
</>
)
}

View file

@ -0,0 +1,215 @@
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";
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.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;
const distance =
temp < lower ? lower - temp : temp - upper;
return Math.min(1, distance / fade);
};
// -----------------------------
// 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]);
// -----------------------------
// BUILD POINT CLOUD FROM NODES
// -----------------------------
const { positions, colors } = useMemo(() => {
const positions: number[] = [];
const colors: number[] = [];
const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR;
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;
for (let pStep = 0; pStep < phiSteps; pStep++) {
// avoid poles clustering by not hitting exact 0/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 distXZ = Math.sqrt(x * x + z * z);
if (distXZ > radiusLimit) continue;
if (y > maxGrainY || y < minY) continue;
positions.push(x, y, z);
const falloff = 1 - r / radius;
// 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);
} else {
colors.push(0, 0, brightness);
}
}
}
}
});
return {
positions: new Float32Array(positions),
colors: new Float32Array(colors),
};
}, [nodes, bin, maxGrainY]);
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>
<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={AdditiveBlending}
/>
</points>
);
}

View file

@ -7,7 +7,7 @@ const GREEN = new Color("#52c41a");
const BLUE = new Color("#3399ff"); const BLUE = new Color("#3399ff");
const RED = new Color("#ff4d4f"); const RED = new Color("#ff4d4f");
const colourFade = 20; export const colourFade = 20;
const minimumLightness = 0.3; const minimumLightness = 0.3;
const lightnessRange = 0.2; const lightnessRange = 0.2;
const minimumSaturation = 0.7; const minimumSaturation = 0.7;
@ -30,7 +30,7 @@ const getVisualIntensity = (distance: number, mode: ColourMode) => {
} }
}; };
export function tempToColour( export function TempToColour(
node: NodeData, node: NodeData,
lower: number, lower: number,
upper: number, upper: number,