updated the heatmap and added toggles
This commit is contained in:
parent
61aa93aabd
commit
b1c676987e
6 changed files with 531 additions and 849 deletions
|
|
@ -46,9 +46,9 @@ export function BuildNodeData(cables: CableData[]): NodeData[] {
|
|||
let t = celcius
|
||||
// for testing
|
||||
if(i===0){
|
||||
t = 30
|
||||
t = 40
|
||||
}
|
||||
if (i===1){
|
||||
if (i===5){
|
||||
t = 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ import { Vector3 } from "three";
|
|||
import { useMemo } from "react";
|
||||
import { BuildCableData, CableData } from "../Data/BuildCableData";
|
||||
import { BuildNodeData, NodeData } from "../Data/BuildNodeData";
|
||||
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";
|
||||
import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; //grain heat map
|
||||
import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; //hot spots
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
|
@ -31,16 +30,24 @@ interface Props {
|
|||
fillPercent?: number
|
||||
nodeClick?: (node: NodeData, cable: CableData) => void
|
||||
/**
|
||||
* When true, renders the heatmap instead of the grain fill.
|
||||
* toggles the grain in the bin
|
||||
*/
|
||||
showGrain?: boolean
|
||||
/**
|
||||
* toggles the heatmap overlay
|
||||
*/
|
||||
showHeatmap?: boolean
|
||||
/**
|
||||
* toggles the hotspots in the bin
|
||||
*/
|
||||
showHotspots?: 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, showHeatmap = false} = props
|
||||
const {bin, scale = 100, fillPercent, nodeClick, showHeatmap, showGrain, showHotspots} = props
|
||||
const binCenter = useMemo(() => new Vector3(0, 0, 0), []);
|
||||
const cableData = useMemo(() => BuildCableData(bin), [bin]);
|
||||
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
|
||||
|
|
@ -86,7 +93,7 @@ export default function Bin3dView(props: Props){
|
|||
/>
|
||||
|
||||
{/* grain - cylinder*/}
|
||||
{/* {!showHeatmap && grainInventory()} */}
|
||||
{showGrain && grainInventory()}
|
||||
{/* cables */}
|
||||
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}
|
||||
onNodeClick={(node, cable) => {
|
||||
|
|
@ -97,8 +104,8 @@ export default function Bin3dView(props: Props){
|
|||
}
|
||||
}}
|
||||
renderOrder={1}/>
|
||||
{/* <NodePointCloud bin={bin} nodes={nodeData} /> */}
|
||||
<TempHeatMap bin={bin} nodes={nodeData}/>
|
||||
{showHotspots && <NodePointCloud bin={bin} nodes={nodeData} /> }
|
||||
{showHeatmap && <TempHeatMap bin={bin} nodes={nodeData}/>}
|
||||
</group>
|
||||
|
||||
{/* lighting */}
|
||||
|
|
|
|||
|
|
@ -72,18 +72,16 @@ export default function CableNode(props: Props) {
|
|||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE).colour()
|
||||
});
|
||||
|
||||
if (node.humidity) {
|
||||
r.push({
|
||||
text: `${node.humidity.toFixed(2)}%`,
|
||||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
|
||||
});
|
||||
}
|
||||
|
||||
if (node.moisture) {
|
||||
r.push({
|
||||
text: `${node.moisture.toFixed(2)}% EMC`,
|
||||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour()
|
||||
});
|
||||
} else if (node.humidity) {
|
||||
r.push({
|
||||
text: `${node.humidity.toFixed(2)}%`,
|
||||
color: describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT).colour()
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
|
|
@ -151,7 +149,7 @@ export default function CableNode(props: Props) {
|
|||
<mesh position={[0, 0, -1]}>
|
||||
<planeGeometry args={[200, labelHeight]} />
|
||||
<meshBasicMaterial
|
||||
color="black"
|
||||
color="white"
|
||||
transparent
|
||||
opacity={0.9}
|
||||
depthWrite={false}
|
||||
|
|
|
|||
|
|
@ -1,452 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,405 +2,490 @@ import { useMemo } from "react";
|
|||
import * as THREE from "three";
|
||||
import { Bin } from "models";
|
||||
import { NodeData } from "../../Data/BuildNodeData";
|
||||
|
||||
import React from "react";
|
||||
|
||||
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 {
|
||||
|
||||
const RADIAL_RINGS = 20;
|
||||
const THETA_SEGMENTS = 40;
|
||||
const HEIGHT_STEPS = 28;
|
||||
|
||||
const YELLOW_DELTA = 5;
|
||||
const RED_DELTA = 10;
|
||||
|
||||
const IDW_POWER = 4;
|
||||
const RED_OPACITY = 0.3;
|
||||
const GREEN_OPACITY = 0.3;
|
||||
const YELLOW_OPACITY = 0.8;
|
||||
|
||||
// New tuning knobs
|
||||
const ANGLE_JITTER = 0.2;
|
||||
const RADIAL_JITTER = 0.05;
|
||||
const LAYER_TWIST = 0.22;
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
|
||||
|
||||
function heatToRGB(heat:number): number[] {
|
||||
|
||||
const GREEN = [0,0.5,0.02];
|
||||
const YELLOW = [0.7,0.86,0.0];
|
||||
const RED = [0.8,0.0,0.01];
|
||||
|
||||
if (heat <= 0)
|
||||
return 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
|
||||
];
|
||||
|
||||
const t = Math.pow(heat,0.75);
|
||||
|
||||
return [
|
||||
GREEN[0] + (YELLOW[0]-GREEN[0])*t,
|
||||
GREEN[1] + (YELLOW[1]-GREEN[1])*t,
|
||||
GREEN[2] + (YELLOW[2]-GREEN[2])*t,
|
||||
];
|
||||
}
|
||||
|
||||
// yellow → red
|
||||
const t = heat - 1;
|
||||
|
||||
const t = Math.pow(heat-1,0.75);
|
||||
|
||||
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
|
||||
YELLOW[0] + (RED[0]-YELLOW[0])*t,
|
||||
YELLOW[1] + (RED[1]-YELLOW[1])*t,
|
||||
YELLOW[2] + (RED[2]-YELLOW[2])*t,
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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;
|
||||
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;
|
||||
|
||||
const weight =
|
||||
1 / Math.pow(distSq, power/2);
|
||||
|
||||
totalWeight += weight;
|
||||
weightedSum += a.celcius * weight;
|
||||
}
|
||||
|
||||
return totalWeight===0
|
||||
? 0
|
||||
: weightedSum/totalWeight;
|
||||
}
|
||||
|
||||
return totalWeight === 0 ? 0 : weightedSum / totalWeight;
|
||||
}
|
||||
// deterministic pseudo-random based on indices
|
||||
function hashNoise(a:number,b:number,c:number){
|
||||
const x = Math.sin(
|
||||
a*127.1 + b*311.7 + c*74.7
|
||||
) * 43758.5453;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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);
|
||||
return (x - Math.floor(x))*2 -1;
|
||||
}
|
||||
export default function TempHeatMapGPT({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;
|
||||
|
||||
const maxRadiusAtY=(y: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<TempAnchor[]>(() =>
|
||||
nodes
|
||||
.filter(n => n.inGrain && !n.excluded)
|
||||
.map(n => ({
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
z: n.position.z,
|
||||
celcius: n.celcius,
|
||||
})),
|
||||
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
|
||||
);
|
||||
|
||||
const maxGrainY=useMemo(()=>{
|
||||
let maxY=sidewallBaseY;
|
||||
|
||||
for(const a of anchors)
|
||||
if(a.y>maxY)
|
||||
maxY=a.y;
|
||||
|
||||
return maxY;
|
||||
},[anchors,sidewallBaseY]);
|
||||
|
||||
const geometry=useMemo(()=>{
|
||||
|
||||
if(!anchors.length)
|
||||
return null;
|
||||
|
||||
const pointsPerLayer=
|
||||
1 + RADIAL_RINGS*THETA_SEGMENTS;
|
||||
|
||||
const totalVerts=
|
||||
HEIGHT_STEPS*pointsPerLayer +1;
|
||||
|
||||
const positions=
|
||||
new Float32Array(totalVerts*3);
|
||||
|
||||
const colors=
|
||||
new Float32Array(totalVerts*3);
|
||||
|
||||
const heats=new Float32Array(totalVerts);
|
||||
|
||||
const rawBottomY=
|
||||
hopperHeight>0
|
||||
? hopperTipY
|
||||
: sidewallBaseY;
|
||||
|
||||
const grainBottomY=
|
||||
hopperHeight>0
|
||||
? hopperTipY +
|
||||
(maxGrainY-hopperTipY)/HEIGHT_STEPS
|
||||
: sidewallBaseY;
|
||||
const grainHeight = maxGrainY - grainBottomY;
|
||||
|
||||
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;
|
||||
|
||||
const layerBase=
|
||||
hStep*pointsPerLayer;
|
||||
|
||||
const centerTemp=
|
||||
idwTemp(
|
||||
0,y,0,
|
||||
anchors,
|
||||
IDW_POWER
|
||||
);
|
||||
|
||||
const centerHeat=
|
||||
tempToHeat(centerTemp,upperThreshold);
|
||||
|
||||
const [cr,cg,cb]=
|
||||
heatToRGB(centerHeat);
|
||||
|
||||
positions[layerBase*3]=0;
|
||||
positions[layerBase*3+1]=y;
|
||||
positions[layerBase*3+2]=0;
|
||||
|
||||
colors[layerBase*3]=cr;
|
||||
colors[layerBase*3+1]=cg;
|
||||
colors[layerBase*3+2]=cb;
|
||||
|
||||
for(let ring=0; ring<RADIAL_RINGS; ring++){
|
||||
|
||||
//biases density towards the walls of the bin
|
||||
const u = (ring+1)/RADIAL_RINGS;
|
||||
const rFrac = 1 - Math.pow(1-u, 2.2);
|
||||
|
||||
for(let seg=0; seg<THETA_SEGMENTS; seg++){
|
||||
|
||||
const noiseA=
|
||||
hashNoise(hStep,ring,seg);
|
||||
|
||||
const noiseR=
|
||||
hashNoise(seg,hStep,ring+11);
|
||||
|
||||
const baseRadius=
|
||||
rFrac*allowedRadius;
|
||||
|
||||
const jitterRadius=
|
||||
baseRadius +
|
||||
noiseR*
|
||||
RADIAL_JITTER*
|
||||
allowedRadius;
|
||||
|
||||
const r=Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
jitterRadius,
|
||||
allowedRadius
|
||||
)
|
||||
);
|
||||
|
||||
const layerPhase=
|
||||
t*LAYER_TWIST;
|
||||
|
||||
const angle=
|
||||
(seg/THETA_SEGMENTS)
|
||||
*Math.PI*2
|
||||
+ layerPhase
|
||||
+ noiseA*ANGLE_JITTER;
|
||||
|
||||
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;
|
||||
|
||||
heats[vi]=heat;
|
||||
|
||||
positions[vi*3]=x;
|
||||
positions[vi*3+1]=y;
|
||||
positions[vi*3+2]=z;
|
||||
|
||||
colors[vi*3]=vr;
|
||||
colors[vi*3+1]=vg;
|
||||
colors[vi*3+2]=vb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idx=(
|
||||
hStep:number,
|
||||
ring:number,
|
||||
seg:number
|
||||
)=>{
|
||||
|
||||
if(ring<0)
|
||||
return hStep*pointsPerLayer;
|
||||
|
||||
const s=
|
||||
((seg%THETA_SEGMENTS)
|
||||
+THETA_SEGMENTS)
|
||||
%THETA_SEGMENTS;
|
||||
|
||||
return hStep*pointsPerLayer+
|
||||
1+
|
||||
ring*THETA_SEGMENTS+
|
||||
s;
|
||||
};
|
||||
|
||||
const green:number[]=[];
|
||||
const yellow:number[]=[];
|
||||
const red:number[]=[];
|
||||
|
||||
function pushTri(a:number,b:number,c:number){
|
||||
|
||||
const avg=(
|
||||
heats[a]+heats[b]+heats[c]
|
||||
)/3;
|
||||
|
||||
if(avg<1)
|
||||
green.push(a,b,c);
|
||||
else if(avg<2)
|
||||
yellow.push(a,b,c);
|
||||
else
|
||||
red.push(a,b,c);
|
||||
}
|
||||
|
||||
for(let h=0; h<HEIGHT_STEPS-1; h++){
|
||||
|
||||
for(let ring=0; ring<RADIAL_RINGS-1; ring++){
|
||||
for(let seg=0; seg<THETA_SEGMENTS; seg++){
|
||||
|
||||
if (grainHeight <= 0) return null;
|
||||
const next=(seg+1)%THETA_SEGMENTS;
|
||||
|
||||
for (let hStep = 0; hStep < HEIGHT_STEPS; hStep++) {
|
||||
const t = hStep / (HEIGHT_STEPS - 1);
|
||||
const y = grainBottomY + t * grainHeight;
|
||||
const a=idx(h,ring,seg);
|
||||
const b=idx(h,ring,next);
|
||||
const e=idx(h+1,ring,seg);
|
||||
const f=idx(h+1,ring,next);
|
||||
|
||||
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 (
|
||||
<mesh geometry={geometry} renderOrder={2}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
// indices.push(a,e,b);
|
||||
// indices.push(e,f,b);
|
||||
pushTri(a,e,b);
|
||||
pushTri(e,f,b);
|
||||
}
|
||||
}
|
||||
|
||||
//center fill
|
||||
for (let seg=0; seg<THETA_SEGMENTS; seg++) {
|
||||
|
||||
const next=(seg+1)%THETA_SEGMENTS;
|
||||
|
||||
const center0 = idx(h,-1,0);
|
||||
const center1 = idx(h+1,-1,0);
|
||||
|
||||
const a = idx(h,0,seg);
|
||||
const b = idx(h,0,next);
|
||||
|
||||
const c = idx(h+1,0,seg);
|
||||
const d = idx(h+1,0,next);
|
||||
|
||||
pushTri(center0,c,a);
|
||||
pushTri(center0,center1,c);
|
||||
pushTri(a,c,b);
|
||||
pushTri(b,c,d);
|
||||
}
|
||||
}
|
||||
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;
|
||||
positions[tipVertexIndex*3+1]=rawBottomY;
|
||||
positions[tipVertexIndex*3+2]=0;
|
||||
|
||||
colors[tipVertexIndex*3]=tr;
|
||||
colors[tipVertexIndex*3+1]=tg;
|
||||
colors[tipVertexIndex*3+2]=tb;
|
||||
const outerRing=RADIAL_RINGS-1;
|
||||
|
||||
for(let seg=0; seg<THETA_SEGMENTS; seg++){
|
||||
const next=(seg+1)%THETA_SEGMENTS;
|
||||
|
||||
const a=idx(0,outerRing,seg);
|
||||
const b=idx(0,outerRing,next);
|
||||
pushTri(tipVertexIndex,b,a)
|
||||
}
|
||||
}
|
||||
|
||||
function makeGeo(indices:number[]){
|
||||
const g=new THREE.BufferGeometry();
|
||||
g.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions,3)
|
||||
);
|
||||
}
|
||||
g.setAttribute(
|
||||
'color',
|
||||
new THREE.BufferAttribute(colors,3)
|
||||
);
|
||||
g.setIndex(indices);
|
||||
return g;
|
||||
}
|
||||
|
||||
return {
|
||||
green:makeGeo(green),
|
||||
yellow:makeGeo(yellow),
|
||||
red:makeGeo(red)
|
||||
};
|
||||
|
||||
},[
|
||||
anchors,
|
||||
maxGrainY,
|
||||
hopperTipY,
|
||||
sidewallBaseY,
|
||||
binRadius,
|
||||
upperThreshold,
|
||||
hopperHeight,
|
||||
]);
|
||||
|
||||
if(!geometry)
|
||||
return null;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<mesh geometry={geometry.green} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={GREEN_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh geometry={geometry.yellow} renderOrder={2}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={YELLOW_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<mesh geometry={geometry.red} renderOrder={3}>
|
||||
<meshBasicMaterial
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={RED_OPACITY}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
}
|
||||
|
|
@ -22,6 +22,9 @@ import {
|
|||
AccordionDetails,
|
||||
Typography,
|
||||
TextField,
|
||||
RadioGroup,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from "@mui/material";
|
||||
import BinActions from "bin/BinActions";
|
||||
import BinHistory from "bin/BinHistory";
|
||||
|
|
@ -212,6 +215,11 @@ export default function Bin(props: Props) {
|
|||
const [binPresets, setBinPresets] = useState<DevicePreset[]>([]);
|
||||
const [missedReadings, setMissedReadings] = useState(0);
|
||||
|
||||
//3d bin variables/toggle
|
||||
const [showGrain, setShowGrain] = useState(false)
|
||||
const [showHotspots, setShowHotspots] = useState(false)
|
||||
const [showHeatmap, setShowHeatmap] = useState(false)
|
||||
|
||||
const handleChange = (_event: React.ChangeEvent<{}>, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
|
@ -816,7 +824,43 @@ export default function Bin(props: Props) {
|
|||
setFillPercent(+e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Bin3dView bin={bin} scale={100} fillPercent={fillPercent/100}/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
value={showGrain}
|
||||
checked={showGrain}
|
||||
onChange={(_, checked) => {
|
||||
setShowGrain(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={"Inventory Toggle"}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
value={showHotspots}
|
||||
checked={showHotspots}
|
||||
onChange={(_, checked) => {
|
||||
setShowHotspots(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={"Hot Spots"}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
value={showHeatmap}
|
||||
checked={showHeatmap}
|
||||
onChange={(_, checked) => {
|
||||
setShowHeatmap(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={"Heatmap"}
|
||||
/>
|
||||
<Bin3dView bin={bin} scale={100} fillPercent={fillPercent/100} showGrain={showGrain} showHeatmap={showHeatmap} showHotspots={showHotspots}/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue