various tweaks and now trying to build heatmaps in two different ways
This commit is contained in:
parent
6f6a062106
commit
61aa93aabd
12 changed files with 932 additions and 244 deletions
|
|
@ -16,6 +16,8 @@ export interface BaseMeshProps {
|
||||||
materialOverride?: React.ReactNode;
|
materialOverride?: React.ReactNode;
|
||||||
side?: Side;
|
side?: Side;
|
||||||
depthWrite?: boolean;
|
depthWrite?: boolean;
|
||||||
|
depthTest?: boolean;
|
||||||
|
renderOrder?: number;
|
||||||
onClick?: (event: ThreeEvent<MouseEvent>) => void;
|
onClick?: (event: ThreeEvent<MouseEvent>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +37,8 @@ export default function BaseMesh(props: BaseMeshProps) {
|
||||||
materialOverride,
|
materialOverride,
|
||||||
side,
|
side,
|
||||||
depthWrite = true,
|
depthWrite = true,
|
||||||
|
depthTest = true,
|
||||||
|
renderOrder = 0,
|
||||||
onClick,
|
onClick,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -50,6 +54,7 @@ export default function BaseMesh(props: BaseMeshProps) {
|
||||||
transparent={isTransparent}
|
transparent={isTransparent}
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
depthWrite={depthWrite}
|
depthWrite={depthWrite}
|
||||||
|
depthTest={depthTest}
|
||||||
side={side}
|
side={side}
|
||||||
{...materialProps}
|
{...materialProps}
|
||||||
/>
|
/>
|
||||||
|
|
@ -64,7 +69,8 @@ export default function BaseMesh(props: BaseMeshProps) {
|
||||||
roughness={roughness}
|
roughness={roughness}
|
||||||
transparent={isTransparent}
|
transparent={isTransparent}
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
depthWrite={!isTransparent}
|
depthWrite={depthWrite}
|
||||||
|
depthTest={depthTest}
|
||||||
side={side}
|
side={side}
|
||||||
{...materialProps}
|
{...materialProps}
|
||||||
/>
|
/>
|
||||||
|
|
@ -73,7 +79,7 @@ export default function BaseMesh(props: BaseMeshProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={position} rotation={rotation}>
|
<group position={position} rotation={rotation} renderOrder={renderOrder}>
|
||||||
{/* Main surface */}
|
{/* Main surface */}
|
||||||
<mesh geometry={geometry} onClick={onClick}>
|
<mesh geometry={geometry} onClick={onClick}>
|
||||||
{buildMaterial()}
|
{buildMaterial()}
|
||||||
|
|
@ -81,7 +87,7 @@ export default function BaseMesh(props: BaseMeshProps) {
|
||||||
|
|
||||||
{/* Optional wireframe overlay */}
|
{/* Optional wireframe overlay */}
|
||||||
{wireframe && (
|
{wireframe && (
|
||||||
<mesh geometry={geometry}>
|
<mesh geometry={geometry} renderOrder={renderOrder}>
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
wireframe
|
wireframe
|
||||||
color={frameColour}
|
color={frameColour}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ interface Props {
|
||||||
roofHeight: number
|
roofHeight: number
|
||||||
hopperHeight?: number
|
hopperHeight?: number
|
||||||
wireframe?: boolean
|
wireframe?: boolean
|
||||||
|
renderOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BinShell(props: Props) {
|
export default function BinShell(props: Props) {
|
||||||
const { radialSegments, binBodyColour, binMetalness, binRoughness, binOpacity, diameter, sidewallHeight, roofHeight, hopperHeight, wireframe } = props
|
const { radialSegments, binBodyColour, binMetalness, binRoughness, binOpacity, diameter, sidewallHeight, roofHeight, hopperHeight, wireframe, renderOrder } = props
|
||||||
const cylinderGeometry = React.useMemo(() => ({
|
const cylinderGeometry = React.useMemo(() => ({
|
||||||
radiusTop: diameter / 2,
|
radiusTop: diameter / 2,
|
||||||
radiusBottom: diameter / 2,
|
radiusBottom: diameter / 2,
|
||||||
|
|
@ -86,7 +87,10 @@ export default function BinShell(props: Props) {
|
||||||
position={roofPosition}
|
position={roofPosition}
|
||||||
roughness={binRoughness}
|
roughness={binRoughness}
|
||||||
side={2}
|
side={2}
|
||||||
opacity={binOpacity}/>
|
opacity={binOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={renderOrder}/>
|
||||||
{/* bin sidewall - cylinder (open ended for the roof and bottom)*/}
|
{/* bin sidewall - cylinder (open ended for the roof and bottom)*/}
|
||||||
<Cylinder
|
<Cylinder
|
||||||
geometry={cylinderGeometry}
|
geometry={cylinderGeometry}
|
||||||
|
|
@ -94,7 +98,10 @@ export default function BinShell(props: Props) {
|
||||||
wireframe= {wireframe}
|
wireframe= {wireframe}
|
||||||
metalness={binMetalness}
|
metalness={binMetalness}
|
||||||
roughness={binRoughness}
|
roughness={binRoughness}
|
||||||
opacity={binOpacity}/>
|
opacity={binOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={renderOrder}/>
|
||||||
{/* bin bottom - cone -OR- circle depending on the bins shape*/}
|
{/* bin bottom - cone -OR- circle depending on the bins shape*/}
|
||||||
{hopperHeight !== undefined && hopperHeight > 0 ?
|
{hopperHeight !== undefined && hopperHeight > 0 ?
|
||||||
<Cone
|
<Cone
|
||||||
|
|
@ -107,6 +114,9 @@ export default function BinShell(props: Props) {
|
||||||
roughness={binRoughness}
|
roughness={binRoughness}
|
||||||
opacity={binOpacity}
|
opacity={binOpacity}
|
||||||
side={2}
|
side={2}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={renderOrder}
|
||||||
/>
|
/>
|
||||||
:
|
:
|
||||||
<Circle
|
<Circle
|
||||||
|
|
@ -119,6 +129,9 @@ export default function BinShell(props: Props) {
|
||||||
roughness={binRoughness}
|
roughness={binRoughness}
|
||||||
opacity={binOpacity}
|
opacity={binOpacity}
|
||||||
side={2}
|
side={2}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={renderOrder}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export function BuildNodeData(cables: CableData[]): NodeData[] {
|
||||||
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
|
let t = celcius
|
||||||
|
// for testing
|
||||||
if(i===0){
|
if(i===0){
|
||||||
t = 30
|
t = 30
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { Vector3 } from "three";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BuildCableData, CableData } from "../Data/BuildCableData";
|
import { BuildCableData, CableData } from "../Data/BuildCableData";
|
||||||
import { BuildNodeData, NodeData } from "../Data/BuildNodeData";
|
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 { pond } from "protobuf-ts/pond";
|
||||||
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
|
import GrainCableFill from "../Systems/Inventory/GrainCableFill";
|
||||||
|
import TempHeatMap from "../Systems/Heatmap/TempHeatMap";
|
||||||
|
// import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,13 +30,17 @@ interface Props {
|
||||||
*/
|
*/
|
||||||
fillPercent?: number
|
fillPercent?: number
|
||||||
nodeClick?: (node: NodeData, cable: CableData) => void
|
nodeClick?: (node: NodeData, cable: CableData) => void
|
||||||
|
/**
|
||||||
|
* When true, renders the heatmap instead of the grain fill.
|
||||||
|
*/
|
||||||
|
showHeatmap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Bin3dView(props: Props){
|
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
|
//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,
|
// 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
|
// 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 binCenter = useMemo(() => new Vector3(0, 0, 0), []);
|
||||||
const cableData = useMemo(() => BuildCableData(bin), [bin]);
|
const cableData = useMemo(() => BuildCableData(bin), [bin]);
|
||||||
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
|
const nodeData = useMemo(() => BuildNodeData(cableData), [cableData]);
|
||||||
|
|
@ -58,6 +64,7 @@ export default function Bin3dView(props: Props){
|
||||||
sidewallHeight={bin.sidewallHeight()}
|
sidewallHeight={bin.sidewallHeight()}
|
||||||
hopperHeight={bin.hopperHeight()}
|
hopperHeight={bin.hopperHeight()}
|
||||||
fillPercent={fillPercent}
|
fillPercent={fillPercent}
|
||||||
|
grainOpacity={0.3}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,11 +82,11 @@ export default function Bin3dView(props: Props){
|
||||||
sidewallHeight={bin.sidewallHeight()}
|
sidewallHeight={bin.sidewallHeight()}
|
||||||
roofHeight={bin.roofHeight()}
|
roofHeight={bin.roofHeight()}
|
||||||
hopperHeight={bin.hopperHeight()}
|
hopperHeight={bin.hopperHeight()}
|
||||||
|
renderOrder={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* grain - cylinder*/}
|
{/* grain - cylinder*/}
|
||||||
{grainInventory()}
|
{/* {!showHeatmap && grainInventory()} */}
|
||||||
{/* cables */}
|
{/* cables */}
|
||||||
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}
|
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}
|
||||||
onNodeClick={(node, cable) => {
|
onNodeClick={(node, cable) => {
|
||||||
|
|
@ -88,8 +95,10 @@ export default function Bin3dView(props: Props){
|
||||||
if(nodeClick){
|
if(nodeClick){
|
||||||
nodeClick(node, cable)
|
nodeClick(node, cable)
|
||||||
}
|
}
|
||||||
}}/>
|
}}
|
||||||
<NodePointCloud bin={bin} nodes={nodeData} />
|
renderOrder={1}/>
|
||||||
|
{/* <NodePointCloud bin={bin} nodes={nodeData} /> */}
|
||||||
|
<TempHeatMap bin={bin} nodes={nodeData}/>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
{/* lighting */}
|
{/* lighting */}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ interface Props {
|
||||||
cableData: CableData[]
|
cableData: CableData[]
|
||||||
nodeData: NodeData[]
|
nodeData: NodeData[]
|
||||||
bin: Bin
|
bin: Bin
|
||||||
binCenter: Vector3
|
binCenter: Vector3,
|
||||||
|
renderOrder?: number,
|
||||||
onNodeClick?: (node: NodeData, cable: CableData) => void
|
onNodeClick?: (node: NodeData, cable: CableData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BinCables(props: Props){
|
export default function BinCables(props: Props){
|
||||||
const {bin, binCenter, cableData, nodeData, onNodeClick} = props
|
const {bin, binCenter, cableData, nodeData, onNodeClick, renderOrder} = props
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{cableData.map((cable, i) => {
|
{cableData.map((cable, i) => {
|
||||||
|
|
@ -32,6 +33,7 @@ export default function BinCables(props: Props){
|
||||||
onNodeClick(node, cable)
|
onNodeClick(node, cable)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
renderOrder={renderOrder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@ interface Props {
|
||||||
node: NodeData
|
node: NodeData
|
||||||
binCenter: Vector3
|
binCenter: Vector3
|
||||||
lowerThreshold: number
|
lowerThreshold: number
|
||||||
upperThreshold: number
|
upperThreshold: number,
|
||||||
|
renderOrder?: number,
|
||||||
onNodeClick?: (node: NodeData) => void
|
onNodeClick?: (node: NodeData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CableNode(props: Props) {
|
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 { camera } = useThree();
|
||||||
const [{ user }] = useGlobalState();
|
const [{ user }] = useGlobalState();
|
||||||
const [showLabel, setShowLabel] = useState(false);
|
const [showLabel, setShowLabel] = useState(false);
|
||||||
|
|
@ -136,9 +137,11 @@ export default function CableNode(props: Props) {
|
||||||
position={node.position}
|
position={node.position}
|
||||||
colour={nodeColour()}
|
colour={nodeColour()}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
renderOrder={renderOrder}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
{node.topNode && (
|
{node.topNode && (
|
||||||
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} />
|
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation} renderOrder={renderOrder}/>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ interface Props {
|
||||||
cable: CableData
|
cable: CableData
|
||||||
nodes: NodeData[]
|
nodes: NodeData[]
|
||||||
bin: Bin
|
bin: Bin
|
||||||
binCenter: Vector3
|
binCenter: Vector3,
|
||||||
|
renderOrder?: number,
|
||||||
onNodeClick?: (node: NodeData) => void
|
onNodeClick?: (node: NodeData) => void
|
||||||
}
|
}
|
||||||
export default function GrainCable(props: Props) {
|
export default function GrainCable(props: Props) {
|
||||||
const {cable, nodes, bin, binCenter, onNodeClick} = props
|
const {cable, nodes, bin, binCenter, onNodeClick, renderOrder} = props
|
||||||
|
|
||||||
const calculateHeight = () => {
|
const calculateHeight = () => {
|
||||||
return cable.topPosition.distanceTo(cable.bottomPosition)
|
return cable.topPosition.distanceTo(cable.bottomPosition)
|
||||||
|
|
@ -39,9 +40,9 @@ export default function GrainCable(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5}/>
|
<Cylinder geometry={geometry} position={calculateCenter()} opacity={0.5} renderOrder={renderOrder} depthWrite={false} depthTest={false}/>
|
||||||
{nodes.map((node, i) => (
|
{nodes.map((node, i) => (
|
||||||
<CableNode onNodeClick={onNodeClick} key={"node " + i} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
|
<CableNode onNodeClick={onNodeClick} key={"node " + i} renderOrder={renderOrder} node={node} binCenter={binCenter} lowerThreshold={bin.lowerTempThreshold()} upperThreshold={bin.upperTempThreshold()}/>
|
||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,71 @@
|
||||||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
|
||||||
import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour";
|
|
||||||
import { Bin } from "models";
|
import { Bin } from "models";
|
||||||
import { pond } from "protobuf-ts/pond";
|
|
||||||
import { useMemo } from "react";
|
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{
|
interface Props{
|
||||||
bin: Bin
|
bin: Bin
|
||||||
nodes: NodeData[]
|
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
|
* @returns
|
||||||
*/
|
*/
|
||||||
export default function Heatmap(props: Props){
|
export default function Heatmap(props: Props){
|
||||||
const {bin, nodes} = props
|
const {
|
||||||
//the steps that control how many 'layers' the heatmap will generate for each direction
|
bin,
|
||||||
const radialSteps = 18; // number of points across the diameter of the pin
|
nodes,
|
||||||
const heightSteps = 20; // number of points up the side of the bin
|
opacity = 0.65, // kept for backward compatibility
|
||||||
const angleSteps = 40; // number of points around the circumfrence of the bin
|
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 = (
|
useThree(); // keep fiber context available if needed later
|
||||||
temp: number,
|
|
||||||
lower: number,
|
|
||||||
upper: number,
|
|
||||||
fade: number
|
|
||||||
) => {
|
|
||||||
if (temp >= lower && temp <= upper) return 0;
|
|
||||||
|
|
||||||
const distance =
|
const sidewallHeight = bin.sidewallHeight();
|
||||||
temp < lower ? lower - temp : temp - upper;
|
const hopperHeight = bin.hopperHeight() ?? 0;
|
||||||
|
const sidewallBaseY = -sidewallHeight / 2;
|
||||||
|
const hopperTipY = sidewallBaseY - hopperHeight;
|
||||||
|
|
||||||
// clamp 0 → 1
|
const inGrainNodes = useMemo(
|
||||||
return Math.min(1, distance / fade);
|
() => nodes.filter((n) => n.inGrain && !n.excluded),
|
||||||
}
|
[nodes],
|
||||||
|
);
|
||||||
|
|
||||||
const getTemperatureAtPoint = (
|
const maxGrainY = useMemo(() => {
|
||||||
point: Vector3,
|
let maxY = -Infinity;
|
||||||
nodes: NodeData[]
|
for (const n of inGrainNodes) maxY = Math.max(maxY, n.position.y);
|
||||||
) => {
|
return Number.isFinite(maxY) ? maxY : sidewallBaseY;
|
||||||
let totalWeight = 0;
|
}, [inGrainNodes, sidewallBaseY]);
|
||||||
let weightedTemp = 0;
|
|
||||||
|
|
||||||
//not sure if we should use a hard coded value or use a percentage of the bins diameter
|
const topNodes = useMemo(
|
||||||
//const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points
|
() => nodes.filter((n) => n.topNode && n.inGrain && !n.excluded),
|
||||||
const maxDistance = bin.diameter() * 0.25;
|
[nodes],
|
||||||
|
);
|
||||||
|
|
||||||
nodes.forEach(node => {
|
const anchors = useMemo(
|
||||||
if (!node.inGrain || node.excluded) return;
|
() =>
|
||||||
|
topNodes.map((n) => ({
|
||||||
|
x: n.position.x,
|
||||||
|
z: n.position.z,
|
||||||
|
y: n.position.y + n.nodeSpacing * 0.5,
|
||||||
|
})),
|
||||||
|
[topNodes],
|
||||||
|
);
|
||||||
|
|
||||||
const distance = point.distanceTo(node.position);
|
const wallY = useMemo(() => {
|
||||||
|
if (anchors.length === 0) return -sidewallHeight / 2;
|
||||||
|
return anchors.reduce((sum, a) => sum + a.y, 0) / anchors.length;
|
||||||
|
}, [anchors, sidewallHeight]);
|
||||||
|
|
||||||
const intensity = getHeatIntensity(
|
const idwHeight = (
|
||||||
node.celcius,
|
x: number,
|
||||||
bin.lowerTempThreshold(),
|
z: number,
|
||||||
bin.upperTempThreshold(),
|
inputAnchors: { x: number; z: number; y: number }[],
|
||||||
colourFade
|
power = 2,
|
||||||
);
|
): number => {
|
||||||
|
let totalWeight = 0;
|
||||||
|
let weightedY = 0;
|
||||||
|
|
||||||
const nodeMaxDistance = maxDistance * (0.5 + intensity);
|
for (const a of inputAnchors) {
|
||||||
// tweakable: 0.5–1.5 range
|
const dx = x - a.x;
|
||||||
|
const dz = z - a.z;
|
||||||
if (distance > nodeMaxDistance) return;
|
const distSq = dx * dx + dz * dz;
|
||||||
|
if (distSq < 0.001) return a.y;
|
||||||
const weight = 1 / (distance * distance + 1);
|
const w = 1 / Math.pow(distSq, power / 2);
|
||||||
|
totalWeight += w;
|
||||||
totalWeight += weight;
|
weightedY += a.y * w;
|
||||||
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
|
return weightedY / totalWeight;
|
||||||
//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 => {
|
const maxRadiusAtY = (y: number, maxR: number): number => {
|
||||||
if (!node.inGrain || node.excluded) return;
|
if (y >= sidewallBaseY) return maxR;
|
||||||
|
if (hopperHeight <= 0 || y <= hopperTipY) return 0;
|
||||||
|
const t = (y - hopperTipY) / hopperHeight; // 0..1
|
||||||
|
return maxR * t;
|
||||||
|
};
|
||||||
|
|
||||||
if (node.position.y > maxY) {
|
const grainSurfaceY = (x: number, z: number, rNorm: number): number => {
|
||||||
maxY = node.position.y;
|
// If we don't have top nodes, use a flat surface at maxGrainY.
|
||||||
}
|
if (anchors.length === 0) return maxGrainY;
|
||||||
});
|
|
||||||
|
|
||||||
return maxY;
|
const rawY = idwHeight(x, z, anchors);
|
||||||
}, [nodes]);
|
|
||||||
|
|
||||||
const samplePoints = useMemo(() => {
|
// Match `GrainCableFill` outer-wall taper so switching isn't jarring.
|
||||||
const points = [];
|
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 radius = bin.diameter() / 2;
|
const evaluateTemp = (px: number, py: number, pz: number): number | null => {
|
||||||
const height = bin.sidewallHeight();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
for (let yStep = 0; yStep < heightSteps; yStep++) {
|
if (totalWeight === 0) return null;
|
||||||
const y = -height / 2 + (yStep / heightSteps) * height;
|
return weightedSum / totalWeight;
|
||||||
if (y > maxGrainY) continue;
|
};
|
||||||
|
|
||||||
for (let rStep = 0; rStep < radialSteps; rStep++) {
|
const tempToHeatColor = (temp: number): Color => {
|
||||||
// const r = (rStep / radialSteps) * radius;
|
// Match your 2D/point visuals: green in-threshold, fade to red/blue as distance grows.
|
||||||
const r = Math.sqrt(rStep / radialSteps) * radius;
|
const lower = bin.lowerTempThreshold();
|
||||||
|
const upper = bin.upperTempThreshold();
|
||||||
|
|
||||||
for (let aStep = 0; aStep < angleSteps; aStep++) {
|
const GREEN = new Color("#52c41a");
|
||||||
const angle = (aStep / angleSteps) * Math.PI * 2;
|
const BLUE = new Color("#3399ff");
|
||||||
|
const RED = new Color("#ff4d4f");
|
||||||
|
|
||||||
const x = Math.cos(angle) * r;
|
if (temp >= lower && temp <= upper) return GREEN;
|
||||||
const z = Math.sin(angle) * r;
|
|
||||||
|
|
||||||
const position = new Vector3(x, y, z);
|
const distance = temp < lower ? lower - temp : temp - upper;
|
||||||
|
const intensity = Math.min(1, distance / colourFade); // 0..1
|
||||||
|
|
||||||
const temp = getTemperatureAtPoint(position, nodes);
|
// 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;
|
||||||
|
|
||||||
points.push({
|
const hsl = { h: 0, s: 1, l: 1 };
|
||||||
position,
|
(temp < lower ? BLUE : RED).getHSL(hsl);
|
||||||
temp
|
|
||||||
});
|
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;
|
return {
|
||||||
}, [bin, nodes]);
|
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 { positions, colors, alphas } = useMemo(() => {
|
const alphaHashedMaterial = useMemo(() => {
|
||||||
const positions: number[] = [];
|
return new ShaderMaterial({
|
||||||
const colors: number[] = [];
|
transparent: !alphaHash,
|
||||||
const alphas: number[] = [];
|
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;
|
||||||
|
|
||||||
samplePoints.forEach((p) => {
|
vec2 p = gl_PointCoord - vec2(0.5);
|
||||||
if (p.temp === null) return;
|
float d = length(p) * 2.0;
|
||||||
const intensity = getHeatIntensity(
|
float mask = smoothstep(1.0, 0.0, d);
|
||||||
p.temp,
|
|
||||||
bin.lowerTempThreshold(),
|
|
||||||
bin.upperTempThreshold(),
|
|
||||||
colourFade
|
|
||||||
);
|
|
||||||
const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade
|
|
||||||
|
|
||||||
const virtualNode: NodeData = {
|
float dev = clamp(vDev, 0.0, 1.0);
|
||||||
cableIndex: -1,
|
float devCurve = pow(dev, uDeviationPower);
|
||||||
nodeIndex: -1,
|
// 0 => green/in-threshold, 1 => strong deviation
|
||||||
radius: 0,
|
float localOpacityFactor = mix(uGreenOpacityFactor, 1.0, devCurve);
|
||||||
position: p.position,
|
float a = clamp(mask * uOpacity * localOpacityFactor, 0.0, 1.0);
|
||||||
celcius: p.temp,
|
|
||||||
inGrain: true,
|
|
||||||
excluded: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const color = TempToColour(
|
if (uAlphaHash > 0.5) {
|
||||||
virtualNode,
|
float n = ign(gl_FragCoord.xy);
|
||||||
bin.lowerTempThreshold(),
|
if (n > a) discard;
|
||||||
bin.upperTempThreshold(),
|
gl_FragColor = vec4(vColor, 1.0);
|
||||||
"heatmap"
|
} else {
|
||||||
);
|
gl_FragColor = vec4(vColor, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}, [alphaHash, bin, deviationPower, greenOpacityFactor, opacity, pointOpacity, pointSize, wallInsetFactor]);
|
||||||
|
|
||||||
if (!color) return;
|
// Fallback: normal points (no OIT)
|
||||||
|
return (
|
||||||
// position
|
<points renderOrder={2} material={alphaHashedMaterial ?? undefined}>
|
||||||
positions.push(p.position.x, p.position.y, p.position.z);
|
<bufferGeometry>
|
||||||
|
<bufferAttribute
|
||||||
// color (normalized 0–1)
|
attach="attributes-position"
|
||||||
colors.push(color.r, color.g, color.b);
|
array={positions}
|
||||||
alphas.push(alpha)
|
count={positions.length / 3}
|
||||||
|
itemSize={3}
|
||||||
});
|
/>
|
||||||
|
<bufferAttribute
|
||||||
return {
|
attach="attributes-color"
|
||||||
positions: new Float32Array(positions),
|
array={colors}
|
||||||
colors: new Float32Array(colors),
|
count={colors.length / 3}
|
||||||
alphas: new Float32Array(alphas)
|
itemSize={3}
|
||||||
};
|
/>
|
||||||
}, [samplePoints, bin]);
|
<bufferAttribute
|
||||||
|
attach="attributes-deviation"
|
||||||
return (
|
array={deviations}
|
||||||
<points>
|
count={deviations.length}
|
||||||
<bufferGeometry>
|
itemSize={1}
|
||||||
<bufferAttribute
|
/>
|
||||||
attach="attributes-position"
|
</bufferGeometry>
|
||||||
array={positions}
|
</points>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||||
import { colourFade } from "bin/3dView/utils/tempToColour";
|
import { colourFade } from "bin/3dView/utils/tempToColour";
|
||||||
import { Bin } from "models";
|
import { Bin } from "models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { AdditiveBlending, CanvasTexture } from "three";
|
import { CanvasTexture, NormalBlending } from "three";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bin: Bin;
|
bin: Bin;
|
||||||
|
|
@ -22,10 +22,10 @@ export default function NodePointCloud(props: Props) {
|
||||||
|
|
||||||
const MIN_EDGE_INTENSITY = 0.2;
|
const MIN_EDGE_INTENSITY = 0.2;
|
||||||
|
|
||||||
const BASE_BRIGHTNESS = 0.2;
|
const BASE_BRIGHTNESS = 0.7;
|
||||||
const INTENSITY_BRIGHTNESS_MULT = 0.5;
|
const INTENSITY_BRIGHTNESS_MULT = 0.7;
|
||||||
|
|
||||||
const OPACITY = 0.7;
|
const OPACITY = 0.3;
|
||||||
const POINT_SIZE = 1;
|
const POINT_SIZE = 1;
|
||||||
|
|
||||||
// IDW power — how sharply nearer nodes dominate the field
|
// IDW power — how sharply nearer nodes dominate the field
|
||||||
|
|
@ -224,7 +224,7 @@ export default function NodePointCloud(props: Props) {
|
||||||
// RENDER
|
// RENDER
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
return (
|
return (
|
||||||
<points>
|
<points renderOrder={2}>
|
||||||
<bufferGeometry>
|
<bufferGeometry>
|
||||||
<bufferAttribute
|
<bufferAttribute
|
||||||
attach="attributes-position"
|
attach="attributes-position"
|
||||||
|
|
@ -247,7 +247,7 @@ export default function NodePointCloud(props: Props) {
|
||||||
transparent
|
transparent
|
||||||
opacity={OPACITY}
|
opacity={OPACITY}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
blending={AdditiveBlending}
|
blending={NormalBlending}
|
||||||
/>
|
/>
|
||||||
</points>
|
</points>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
406
src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
Normal file
406
src/bin/3dView/Systems/Heatmap/TempHeatMap.tsx
Normal file
|
|
@ -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<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,
|
||||||
|
})),
|
||||||
|
[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 (
|
||||||
|
<mesh geometry={geometry} renderOrder={2}>
|
||||||
|
<meshBasicMaterial
|
||||||
|
vertexColors
|
||||||
|
transparent
|
||||||
|
opacity={OPACITY}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -260,13 +260,30 @@ export default function GrainCableFill(props: Props) {
|
||||||
roughness={1}
|
roughness={1}
|
||||||
metalness={0}
|
metalness={0}
|
||||||
opacity={grainOpacity}
|
opacity={grainOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cylinderFillHeight > 0 && (
|
{cylinderFillHeight > 0 && (
|
||||||
<mesh position={new Vector3(0, -sidewallHeight / 2 + cylinderFillHeight / 2, 0)}>
|
<Cylinder
|
||||||
<cylinderGeometry args={[fbRadius, fbRadius, cylinderFillHeight, 20, 1, false]} />
|
geometry={{
|
||||||
<meshStandardMaterial color={grainColour} roughness={1} metalness={0} opacity={grainOpacity}/>
|
radiusTop: fbRadius,
|
||||||
</mesh>
|
radiusBottom: fbRadius,
|
||||||
|
height: cylinderFillHeight,
|
||||||
|
radialSegments: 20,
|
||||||
|
heightSegments: 1,
|
||||||
|
openEnded: false
|
||||||
|
}}
|
||||||
|
position={new Vector3(0, -sidewallHeight / 2 + cylinderFillHeight / 2, 0)}
|
||||||
|
colour={grainColour}
|
||||||
|
roughness={1}
|
||||||
|
metalness={0}
|
||||||
|
opacity={grainOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={0}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -275,9 +292,9 @@ export default function GrainCableFill(props: Props) {
|
||||||
// --- Render cable-driven surface ---
|
// --- Render cable-driven surface ---
|
||||||
return (
|
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 && (
|
{surfaceGeometry && (
|
||||||
<mesh geometry={surfaceGeometry}>
|
<mesh renderOrder={0} geometry={surfaceGeometry}>
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={grainColour}
|
color={grainColour}
|
||||||
roughness={1}
|
roughness={1}
|
||||||
|
|
@ -286,6 +303,7 @@ export default function GrainCableFill(props: Props) {
|
||||||
transparent
|
transparent
|
||||||
opacity={grainOpacity}
|
opacity={grainOpacity}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
@ -305,6 +323,8 @@ export default function GrainCableFill(props: Props) {
|
||||||
roughness={1}
|
roughness={1}
|
||||||
opacity={grainOpacity}
|
opacity={grainOpacity}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hopper fill — always full when grain surface exists above the floor */}
|
{/* Hopper fill — always full when grain surface exists above the floor */}
|
||||||
|
|
@ -322,6 +342,9 @@ export default function GrainCableFill(props: Props) {
|
||||||
roughness={1}
|
roughness={1}
|
||||||
metalness={0}
|
metalness={0}
|
||||||
opacity={grainOpacity}
|
opacity={grainOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface Props {
|
||||||
sidewallHeight: number;
|
sidewallHeight: number;
|
||||||
hopperHeight?: number;
|
hopperHeight?: number;
|
||||||
fillPercent: number;
|
fillPercent: number;
|
||||||
|
grainOpacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GrainFillFlat(props: Props) {
|
export default function GrainFillFlat(props: Props) {
|
||||||
|
|
@ -15,7 +16,8 @@ export default function GrainFillFlat(props: Props) {
|
||||||
diameter,
|
diameter,
|
||||||
sidewallHeight,
|
sidewallHeight,
|
||||||
hopperHeight = 0,
|
hopperHeight = 0,
|
||||||
fillPercent
|
fillPercent,
|
||||||
|
grainOpacity
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const radius = diameter / 2;
|
const radius = diameter / 2;
|
||||||
|
|
@ -99,6 +101,10 @@ export default function GrainFillFlat(props: Props) {
|
||||||
colour={grainColour}
|
colour={grainColour}
|
||||||
roughness={1}
|
roughness={1}
|
||||||
metalness={0}
|
metalness={0}
|
||||||
|
opacity={grainOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -116,6 +122,10 @@ export default function GrainFillFlat(props: Props) {
|
||||||
colour={grainColour}
|
colour={grainColour}
|
||||||
roughness={1}
|
roughness={1}
|
||||||
metalness={0}
|
metalness={0}
|
||||||
|
opacity={grainOpacity}
|
||||||
|
depthWrite={false}
|
||||||
|
depthTest={false}
|
||||||
|
renderOrder={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue