From 874be1d8b7213d042145738d3cb468902821ab27 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Thu, 30 Apr 2026 09:42:02 -0600 Subject: [PATCH] changed the heatmap limit to use the grain height --- src/3dModels/Shapes/BaseMesh.tsx | 4 +- src/bin/3dView/Scene/Bin3dView.tsx | 159 ++-- .../3dView/Systems/Heatmap/TempHeatMap.tsx | 851 +++++++++--------- 3 files changed, 489 insertions(+), 525 deletions(-) diff --git a/src/3dModels/Shapes/BaseMesh.tsx b/src/3dModels/Shapes/BaseMesh.tsx index e06d622..73ea434 100644 --- a/src/3dModels/Shapes/BaseMesh.tsx +++ b/src/3dModels/Shapes/BaseMesh.tsx @@ -79,9 +79,9 @@ export default function BaseMesh(props: BaseMeshProps) { }; return ( - + {/* Main surface */} - + {buildMaterial()} diff --git a/src/bin/3dView/Scene/Bin3dView.tsx b/src/bin/3dView/Scene/Bin3dView.tsx index a9124c7..68b5373 100644 --- a/src/bin/3dView/Scene/Bin3dView.tsx +++ b/src/bin/3dView/Scene/Bin3dView.tsx @@ -1,3 +1,4 @@ + import OrbitCameraControls from "3dModels/CameraControls/OrbitCameraControls"; import { Canvas } from "@react-three/fiber"; import { Bin } from "models"; @@ -10,53 +11,56 @@ import { BuildCableData, CableData } from "../Data/BuildCableData"; import { BuildNodeData, NodeData } from "../Data/BuildNodeData"; import { pond } from "protobuf-ts/pond"; import GrainCableFill from "../Systems/Inventory/GrainCableFill"; -import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; //grain heat map -import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; //hot spots - +import TempHeatMap from "../Systems/Heatmap/TempHeatMap"; +import NodePointCloud from "../Systems/Heatmap/NodePointCloud"; + interface Props { - /** - * The bin to generate a 3D model of - */ bin: Bin - /** - * The scale to apply to the bin dimensions - * ie 100 would make the bin 1:100 scale - * @default 100 - */ scale: number - /** - * optional: the percent of the bin to fill using a level top, this will be used with manual and lidar controls - */ fillPercent?: number nodeClick?: (node: NodeData, cable: CableData) => void - /** - * 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, showGrain, showHotspots} = props + +export default function Bin3dView(props: 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]); - + const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]); + + const isCableInventory = + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || + bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE; + + // For cable inventory, TempHeatMap derives the wavy surface directly from + // the top nodes in nodeData — no scalar needed. + // + // For flat fill percent, we convert the fill percent to a Y coordinate + // using the same volume math as GrainFillFlat and pass it as flatMaxY. + const flatMaxY = useMemo(() => { + if (isCableInventory || fillPercent === undefined) return undefined; + + const radius = bin.diameter() / 2; + const sidewallH = bin.sidewallHeight(); + const hopperH = bin.hopperHeight() ?? 0; + const cylVolume = Math.PI * radius * radius * sidewallH; + const hopperVol = hopperH > 0 ? (1 / 3) * Math.PI * radius * radius * hopperH : 0; + const filled = (cylVolume + hopperVol) * fillPercent; + + const cylinderFillH = hopperH > 0 && filled <= hopperVol + ? 0 + : Math.max(0, (filled - hopperVol) / (Math.PI * radius * radius)); + + return -sidewallH / 2 + cylinderFillH; + }, [isCableInventory, fillPercent, bin]); + const grainInventory = () => { - if(bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_AUTOMATIC || - bin.inventoryControl() === pond.BinInventoryControl.BIN_INVENTORY_CONTROL_HYBRID_CABLE){ + if (isCableInventory) { return ( - ) - }else if (fillPercent){ - + } else if (fillPercent) { + return ( + + ) } } - + return ( - - - + + - - {/* grain - cylinder*/} + {showGrain && grainInventory()} - {/* cables */} - { - // console.log(node) - // console.log(cable) - if(nodeClick){ - nodeClick(node, cable) - } + if (nodeClick) nodeClick(node, cable) }} - renderOrder={1}/> - {showHotspots && } - {showHeatmap && } - - - {/* lighting */} - - - - - - - + renderOrder={1} + /> + + {showHotspots && } + + {showHeatmap && ( + + )} + + + + + + + + + ) -} \ No newline at end of file +} diff --git a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx index a2ab743..ee6e46c 100644 --- a/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx +++ b/src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx @@ -3,489 +3,440 @@ import * as THREE from "three"; import { Bin } from "models"; import { NodeData } from "../../Data/BuildNodeData"; import React from "react"; - + interface Props { bin: Bin; nodes: NodeData[]; + /** + * For flat fill percent inventory — a scalar Y ceiling for the heatmap top. + * Used when there are no cable top nodes to drive a surface. + */ + flatMaxY?: 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 { + +// IDW power for the horizontal surface interpolation — +// matches GrainCableFill's default of 2 +const SURFACE_IDW_POWER = 2; + +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; } - -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; - + +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) { - - 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, - ]; + 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, + ]; } - - const t = Math.pow(heat-1,0.75); - + + const t = Math.pow(heat - 1, 0.75); return [ - YELLOW[0] + (RED[0]-YELLOW[0])*t, - YELLOW[1] + (RED[1]-YELLOW[1])*t, - YELLOW[2] + (RED[2]-YELLOW[2])*t, + YELLOW[0] + (RED[0] - YELLOW[0]) * t, + YELLOW[1] + (RED[1] - YELLOW[1]) * t, + YELLOW[2] + (RED[2] - YELLOW[2]) * t, ]; - } - +} + 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; - - const weight = - 1 / Math.pow(distSq, power/2); - - totalWeight += weight; - weightedSum += a.celcius * weight; + x: number; y: number; z: number; + celcius: number; +} + +interface SurfaceAnchor { + x: number; z: number; y: number; +} + +// 3D IDW for temperature interpolation +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; - } - // 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; + return totalWeight === 0 ? 0 : weightedSum / totalWeight; +} - 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); +// 2D IDW for surface height interpolation — matches GrainCableFill exactly +function idwSurfaceY( + x: number, z: number, + anchors: SurfaceAnchor[], + power: number +): number { + let totalWeight = 0; + let weightedY = 0; + + for (const a of anchors) { + const dx = x - a.x; + const dz = z - a.z; + const distSq = dx * dx + dz * dz; + if (distSq < 0.001) return a.y; + const weight = 1 / Math.pow(distSq, power / 2); + totalWeight += weight; + weightedY += a.y * weight; + } + + return totalWeight === 0 ? 0 : weightedY / totalWeight; +} + +function hashNoise(a: number, b: number, c: number) { + const x = Math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} + +export default function TempHeatMap({ bin, nodes, flatMaxY }: 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); }; - 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, - })), + + // Temperature anchors — all in-grain nodes + const tempAnchors = 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] - ); - - 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 + ); + + // Surface anchors — top nodes only, Y offset by half spacing + // Matches GrainCableFill's anchor computation exactly. + const surfaceAnchors = useMemo( + () => + nodes + .filter(n => n.topNode && n.inGrain && !n.excluded) + .map(n => ({ + x: n.position.x, + z: n.position.z, + y: n.position.y + n.nodeSpacing * 0.5, + })), + [nodes] + ); + + // Wall clamp Y — average of surface anchor heights. + // Prevents the surface from piling up at the bin wall where there are no cables. + // Matches GrainCableFill's wallY computation. + const wallY = useMemo(() => { + if (surfaceAnchors.length === 0) return flatMaxY ?? sidewallBaseY; + return surfaceAnchors.reduce((sum, a) => sum + a.y, 0) / surfaceAnchors.length; + }, [surfaceAnchors, flatMaxY, sidewallBaseY]); + + // For each (x, z) position, returns the Y ceiling of the grain surface. + // Uses cable surface IDW when top nodes exist, falls back to flatMaxY. + const getSurfaceY = (x: number, z: number): number => { + if (surfaceAnchors.length > 0) { + // Outermost ring clamps to wallY — matches GrainCableFill + const raw = idwSurfaceY(x, z, surfaceAnchors, SURFACE_IDW_POWER); + return Math.max(sidewallBaseY, Math.min(sidewallHeight / 2, raw)); + } + return flatMaxY ?? sidewallBaseY; + }; + + // Global max Y — highest point of the surface (used for grid bottom calc) + const maxGrainY = useMemo(() => { + if (surfaceAnchors.length > 0) { + return Math.max(...surfaceAnchors.map(a => a.y), wallY); + } + return flatMaxY ?? sidewallBaseY; + }, [surfaceAnchors, wallY, flatMaxY, sidewallBaseY]); + + const geometry = useMemo(() => { + if (!tempAnchors.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; - - if(grainHeight<=0) - return null; - - for(let hStep=0; hStep{ - - 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; h0){ - - 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 { + 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++) { + const next = (seg + 1) % THETA_SEGMENTS; + 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); + 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); + } + } + + // Hopper tip + const tipVertexIndex = HEIGHT_STEPS * pointsPerLayer; + if (hopperHeight > 0) { + const tipTemp = idwTemp(0, rawBottomY, 0, tempAnchors, 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; + heats[tipVertexIndex] = tipHeat; + + const outerRing = RADIAL_RINGS - 1; + for (let seg = 0; seg < THETA_SEGMENTS; seg++) { + const next = (seg + 1) % THETA_SEGMENTS; + pushTri(tipVertexIndex, idx(0, outerRing, next), idx(0, outerRing, seg)); + } + } + + 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), + }; + }, [ + tempAnchors, + surfaceAnchors, + wallY, + maxGrainY, + hopperTipY, + sidewallBaseY, + binRadius, + upperThreshold, + hopperHeight, + flatMaxY, + ]); + + if (!geometry) return null; + + return ( + + + + + + + + + + + + + ); - 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 ( - - - - - - - - - - - - - - ); - -} \ No newline at end of file +}