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 ( {showFlow && sim.segments.map((seg, i) => )} ); } // --------------------------------------------------------------------------- // 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 }) { 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(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 ( {Array.from({ length: count }, (_, i) => ( ))} ); }