252 lines
8.1 KiB
TypeScript
252 lines
8.1 KiB
TypeScript
import { useMemo, useRef } from 'react';
|
||
import { useFrame } from '@react-three/fiber';
|
||
import * as THREE from 'three';
|
||
import type { FlowSegment, PlacedPart, SimResult } from '../types';
|
||
import { useBuilder } from '../store/builderStore';
|
||
import { useSimulation } from '../simulation/flowSimulator';
|
||
import {
|
||
ensureWaterVisual,
|
||
freezeWaterClock,
|
||
getWaterTime,
|
||
getWaterVisual,
|
||
tickWaterClock,
|
||
waterVisuals,
|
||
} from '../simulation/waterState';
|
||
|
||
/**
|
||
* FlowArrows — mount root for all time-based water visual systems:
|
||
*
|
||
* - WaterDriver (always mounted): advances a global fill wavefront each
|
||
* frame and writes per-part fill state into the shared water map
|
||
* (simulation/waterState), which the meshes in PartBody read in their
|
||
* own useFrame callbacks. No React state is touched per frame.
|
||
* - Direction arrows (gated by the showFlow toggle): small cones marching
|
||
* along flowing segments, fading in/out at segment ends and appearing
|
||
* only once the pipe has actually filled with water.
|
||
*/
|
||
export function FlowArrows() {
|
||
const showFlow = useBuilder((s) => s.showFlow);
|
||
const parts = useBuilder((s) => s.parts);
|
||
const sim = useSimulation();
|
||
return (
|
||
<group>
|
||
<WaterDriver sim={sim} parts={parts} />
|
||
{showFlow &&
|
||
sim.segments.map((seg, i) => <ArrowStream key={`${seg.partId}-${i}`} seg={seg} />)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Water fill driver
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Module-level so remounts / panel toggles don't reset the animation.
|
||
let waveFront = 0;
|
||
|
||
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||
const clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x);
|
||
|
||
const GAL_PER_FT3 = 7.48;
|
||
|
||
/** Approximate internal water volume (gal) — sets how fast a part fills. */
|
||
function partVolumeGal(part: PlacedPart, pathLen: number): number {
|
||
const p = part.params;
|
||
const d = Math.max(0.25, p.diameter ?? 1);
|
||
switch (part.type) {
|
||
case 'pipe':
|
||
case 'elbow':
|
||
case 'tee':
|
||
case 'valve':
|
||
// Bore volume: π·r² × path length (d inches → ft).
|
||
return Math.PI * Math.pow(d / 24, 2) * pathLen * GAL_PER_FT3;
|
||
case 'pump':
|
||
return 0.06;
|
||
case 'tower':
|
||
return 0.1 * (p.height ?? 4);
|
||
case 'tray':
|
||
return 0.08 * (p.length ?? 3) * (p.width ?? 1.2);
|
||
case 'wallPanel':
|
||
return 0.25;
|
||
case 'emitter':
|
||
return 0.02;
|
||
case 'drain':
|
||
return 0.05;
|
||
case 'reservoir':
|
||
return 2;
|
||
default:
|
||
return 0.1;
|
||
}
|
||
}
|
||
|
||
interface WaveSpan {
|
||
dist: number;
|
||
len: number;
|
||
/** Wavefront speed through this part (ft/s) ≈ len · GPH / volume. */
|
||
speed: number;
|
||
}
|
||
|
||
function WaterDriver({ sim, parts }: { sim: SimResult; parts: Record<string, PlacedPart> }) {
|
||
const simRef = useRef(sim);
|
||
simRef.current = sim;
|
||
|
||
// Per-part fill data, recomputed only when the sim solution changes.
|
||
const wave = useMemo(() => {
|
||
const partIds = Object.keys(sim.fillLength);
|
||
const spans: WaveSpan[] = [];
|
||
for (const pid of partIds) {
|
||
const dist = sim.fillDistance[pid];
|
||
const flow = sim.flows[pid] ?? 0;
|
||
const part = parts[pid];
|
||
if (dist === undefined || flow <= 0 || !part) continue;
|
||
const len = Math.max(0.2, sim.fillLength[pid]);
|
||
const vol = Math.max(0.004, partVolumeGal(part, len));
|
||
spans.push({ dist, len, speed: clamp((len * (flow / 3600)) / vol, 0.5, 5) });
|
||
}
|
||
return { partIds, spans };
|
||
}, [sim, parts]);
|
||
const waveRef = useRef(wave);
|
||
waveRef.current = wave;
|
||
|
||
useFrame((_, rawDt) => {
|
||
// Master play/pause: freeze ALL water animation in place.
|
||
if (!useBuilder.getState().simRunning) {
|
||
freezeWaterClock();
|
||
return;
|
||
}
|
||
const s = simRef.current;
|
||
const dt = Math.min(rawDt, 0.1);
|
||
tickWaterClock(dt);
|
||
|
||
// Advance the wavefront while pumping; retreat (drain back) when idle.
|
||
// The front moves at the speed of the slowest part it is currently
|
||
// filling (∝ GPH / part volume), so fat tanks fill slower than thin pipes.
|
||
const flowing = s.totalGph > 0.5;
|
||
if (flowing) {
|
||
let speed = Infinity;
|
||
for (const span of waveRef.current.spans) {
|
||
if (waveFront >= span.dist && waveFront < span.dist + span.len) {
|
||
speed = Math.min(speed, span.speed);
|
||
}
|
||
}
|
||
if (!Number.isFinite(speed)) speed = 2; // bridging a junction gap
|
||
waveFront = Math.min(waveFront + speed * dt, s.totalPathLength + 1);
|
||
} else {
|
||
waveFront = Math.max(waveFront - 5 * dt, 0);
|
||
}
|
||
|
||
for (const pid of waveRef.current.partIds) ensureWaterVisual(pid);
|
||
|
||
for (const [pid, v] of waterVisuals) {
|
||
if (!(pid in s.fillLength)) {
|
||
// Part was deleted — let its water vanish, then drop the entry.
|
||
v.fill = Math.max(0, v.fill - dt * 1.5);
|
||
if (v.fill <= 0) waterVisuals.delete(pid);
|
||
continue;
|
||
}
|
||
const flow = s.flows[pid] ?? 0;
|
||
const backed = s.backedUp.has(pid);
|
||
const dist = s.fillDistance[pid];
|
||
const len = Math.max(0.2, s.fillLength[pid]);
|
||
|
||
if (flow > 0 && dist !== undefined) {
|
||
// A part D ft from the pump fills once the wavefront passes D.
|
||
v.fill = clamp01((waveFront - dist) / len);
|
||
} else if (backed) {
|
||
v.fill = Math.min(1, v.fill + dt * 0.6); // stagnant water backing up
|
||
} else {
|
||
v.fill = Math.max(0, v.fill - dt * 0.9); // no supply — drain out
|
||
}
|
||
|
||
v.flow = flow;
|
||
v.backedUp = backed;
|
||
v.overflow = false;
|
||
v.spill = false;
|
||
v.entry = s.entryConnector[pid];
|
||
v.state = backed
|
||
? 'backedUp'
|
||
: v.fill <= 0.002
|
||
? 'empty'
|
||
: v.fill >= 0.998
|
||
? 'full'
|
||
: 'filling';
|
||
}
|
||
|
||
for (const d of s.drains) {
|
||
if (!d.overflowing) continue;
|
||
const v = waterVisuals.get(d.partId);
|
||
if (v) v.overflow = true;
|
||
}
|
||
for (const pot of s.netPots) {
|
||
if (!pot.overflowing) continue;
|
||
const v = ensureWaterVisual(pot.partId);
|
||
v.fill = 1;
|
||
v.flow = pot.inflowGph;
|
||
v.backedUp = true;
|
||
v.overflow = false;
|
||
v.spill = true;
|
||
v.state = 'backedUp';
|
||
}
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Direction arrows
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const UP = new THREE.Vector3(0, 1, 0);
|
||
|
||
function ArrowStream({ seg }: { seg: FlowSegment }) {
|
||
const groupRef = useRef<THREE.Group>(null);
|
||
|
||
const { from, dir, len, quat, count, speed } = useMemo(() => {
|
||
const from = new THREE.Vector3(...seg.from);
|
||
const to = new THREE.Vector3(...seg.to);
|
||
const dir = to.clone().sub(from);
|
||
const len = dir.length();
|
||
dir.normalize();
|
||
// Arrow speed tracks water VELOCITY (∝ GPH / d²), not raw GPH — the same
|
||
// flow squeezed through a narrow pipe visibly rushes.
|
||
const dia = Math.max(0.5, seg.diameter ?? 1);
|
||
return {
|
||
from,
|
||
dir,
|
||
len,
|
||
quat: new THREE.Quaternion().setFromUnitVectors(UP, dir),
|
||
count: Math.max(1, Math.round(len / 0.55)),
|
||
speed: 0.35 + Math.min(2.5, seg.gph / (dia * dia) / 220),
|
||
};
|
||
}, [seg]);
|
||
|
||
useFrame(() => {
|
||
const g = groupRef.current;
|
||
if (!g) return;
|
||
const t = getWaterTime() * speed;
|
||
// Arrows only appear once the water has actually reached this part.
|
||
const fill = getWaterVisual(seg.partId)?.fill ?? 1;
|
||
g.children.forEach((child, i) => {
|
||
const f = (((i + t) % count) + count) % count; // 0..count
|
||
const u = f / count;
|
||
child.position.copy(from).addScaledVector(dir, u * len);
|
||
const mat = (child as THREE.Mesh).material as THREE.MeshBasicMaterial;
|
||
// Fade in/out near the segment ends.
|
||
mat.opacity = 0.85 * fill * Math.min(1, Math.min(u, 1 - u) * 4 + 0.15);
|
||
});
|
||
});
|
||
|
||
if (len < 0.2) return null;
|
||
|
||
return (
|
||
<group ref={groupRef}>
|
||
{Array.from({ length: count }, (_, i) => (
|
||
<mesh key={i} quaternion={quat}>
|
||
<coneGeometry args={[0.05, 0.14, 8]} />
|
||
<meshBasicMaterial color="#38bdf8" toneMapped={false} transparent opacity={0.85} />
|
||
</mesh>
|
||
))}
|
||
</group>
|
||
);
|
||
}
|