roxlap_scene/water.rs
1//! WT.0 — water volumes (`PORTING-WATER.md`): the *physics*
2//! representation of water.
3//!
4//! Why not the CC.4 passable-veto: the veto classifies by voxel
5//! colour, and `.vxl` slab interiors are colourless by format
6//! (`Cube::UnexposedSolid`) — a filled pool has a core the veto never
7//! sees, so colour-keyed water works only ~2 voxels deep. A
8//! [`WaterVolume`] is instead declared *beside* the voxels: an
9//! axis-aligned grid-local box that answers "is this point in water,
10//! and how deep?" independently of what the slabs store. The visuals
11//! stay voxels (fill the same region with a
12//! [`BlendMode::Volumetric`](roxlap_formats::material::BlendMode)
13//! material); the veto stays useful for thin pass-through curtains.
14//!
15//! Conventions (chapter 2: **+z is DOWN**):
16//! - `lo`/`hi` are **inclusive** voxel corners; voxel `(x, y, z)`
17//! occupies the continuous cube `[x, x+1)³`, so the volume spans
18//! `[lo, hi + 1)` in continuous grid-local coordinates.
19//! - The **surface is the volume's top face** — the plane
20//! `z = lo.z` (smaller z is higher).
21//! - Volumes are grid-local: a scaled grid's world waterline moves
22//! with `voxel_world_size`, and a rotated grid's surface tilts with
23//! it. Water is *usually* authored on identity/yaw-only grids so
24//! the surface stays world-horizontal; the queries are exact under
25//! any transform regardless.
26//!
27//! Authoring state only, like [`crate::BakeLight`]s: editing the list
28//! re-renders nothing by itself (the *visual* water is voxels you
29//! edit separately). Persisted in snapshots since wire **v3**.
30
31use glam::{DVec3, IVec3};
32use serde::{Deserialize, Serialize};
33
34use crate::{GridId, Scene};
35
36/// An axis-aligned box of water in grid-local voxel coordinates,
37/// inclusive on both corners. See the module doc for the coordinate
38/// conventions (surface = `lo.z`, z-down).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub struct WaterVolume {
41 /// Minimum corner (inclusive). `lo.z` is the **surface** layer.
42 pub lo: IVec3,
43 /// Maximum corner (inclusive). `hi.z` is the bottom layer.
44 pub hi: IVec3,
45}
46
47impl WaterVolume {
48 /// A volume spanning the two corners in any order (canonicalised
49 /// so `lo <= hi` per component — `PartialEq` compares fields, so
50 /// prefer this over a raw struct literal).
51 #[must_use]
52 pub fn new(a: IVec3, b: IVec3) -> Self {
53 Self {
54 lo: a.min(b),
55 hi: a.max(b),
56 }
57 }
58
59 /// Depth of a continuous grid-local point below this volume's
60 /// surface, in **voxel units**: `Some(distance below the min-z
61 /// face)` when the point is inside the water, `None` outside.
62 /// Zero exactly at the surface plane; positive going down
63 /// (z-down).
64 ///
65 /// Corner order is normalised HERE, not assumed: the fields are
66 /// pub (and easy to swap under z-down), so a hand-built
67 /// `WaterVolume { lo: big, hi: small }` queries identically to
68 /// its [`Self::new`]-canonicalised form — and identically before
69 /// and after a snapshot round-trip.
70 #[must_use]
71 pub fn depth_local(&self, p: DVec3) -> Option<f64> {
72 let lo = self.lo.min(self.hi).as_dvec3();
73 let hi = self.lo.max(self.hi).as_dvec3() + DVec3::ONE; // inclusive → half-open
74 let inside =
75 p.x >= lo.x && p.x < hi.x && p.y >= lo.y && p.y < hi.y && p.z >= lo.z && p.z < hi.z;
76 inside.then_some(p.z - lo.z)
77 }
78}
79
80impl crate::Grid {
81 /// Register a water volume spanning the two grid-local voxel
82 /// corners (any order). Convenience over pushing onto
83 /// [`water_volumes`](crate::Grid::water_volumes) directly — this
84 /// normalises the corners.
85 pub fn add_water_volume(&mut self, a: IVec3, b: IVec3) {
86 self.water_volumes.push(WaterVolume::new(a, b));
87 }
88
89 /// Depth of a continuous **grid-local** point below the water
90 /// surface, in voxel units — the deepest submersion across this
91 /// grid's volumes (overlapping volumes: the highest surface
92 /// wins). `None` when the point is in no volume.
93 #[must_use]
94 pub fn water_depth_local(&self, p: DVec3) -> Option<f64> {
95 self.water_volumes
96 .iter()
97 .filter_map(|v| v.depth_local(p))
98 .max_by(f64::total_cmp)
99 }
100}
101
102impl Scene {
103 /// Depth of a **world**-space point below a water surface, in
104 /// **world units**, with the grid that owns the deepest water at
105 /// that point. `None` when no grid's water contains the point.
106 ///
107 /// The world↔grid boundary rule (SC) applies: the point is
108 /// converted into each grid's local voxel frame (un-rotate,
109 /// un-translate, `/ voxel_world_size`), the depth is measured
110 /// along the grid's **local** z (world-vertical for the usual
111 /// identity/yaw-only water grids), and converted back to world
112 /// units (`× voxel_world_size`).
113 ///
114 /// Deterministic: on an exact depth tie between grids (two
115 /// identity grids sharing a waterline at their seam), the
116 /// smallest [`GridId`] wins — [`Scene::grids`] iterates a
117 /// `HashMap`, so without the explicit tie-break the winner would
118 /// flip between runs (and WT.2/WT.3 hang per-grid state off this
119 /// id).
120 #[must_use]
121 pub fn water_depth_at(&self, world: DVec3) -> Option<(GridId, f64)> {
122 self.grids()
123 .filter(|(_, grid)| !grid.water_volumes.is_empty())
124 .filter_map(|(id, grid)| {
125 let local = crate::streaming::world_to_grid_local_pos(world, &grid.transform);
126 let d_local = grid.water_depth_local(local)?;
127 Some((id, d_local * grid.transform.voxel_world_size))
128 })
129 .max_by(|a, b| a.1.total_cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
130 }
131
132 /// `true` when the world-space point is inside any water volume.
133 /// Short-circuits on the first hit — the query the swim state
134 /// (WT.1) runs per actor per frame; use
135 /// [`Self::water_depth_at`] only when the depth is needed.
136 #[must_use]
137 pub fn in_water(&self, world: DVec3) -> bool {
138 self.grids()
139 .filter(|(_, grid)| !grid.water_volumes.is_empty())
140 .any(|(_, grid)| {
141 let local = crate::streaming::world_to_grid_local_pos(world, &grid.transform);
142 grid.water_depth_local(local).is_some()
143 })
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::GridTransform;
151 use glam::DQuat;
152
153 // ---- WaterVolume geometry ----
154
155 #[test]
156 fn corners_normalise_and_span_is_inclusive() {
157 let v = WaterVolume::new(IVec3::new(9, 9, 9), IVec3::new(2, 3, 4));
158 assert_eq!(v.lo, IVec3::new(2, 3, 4));
159 assert_eq!(v.hi, IVec3::new(9, 9, 9));
160 // Inclusive hi: the far corner voxel's interior is inside...
161 assert!(v.depth_local(DVec3::new(9.5, 9.5, 9.5)).is_some());
162 // ...and the half-open continuous edge just past it is not.
163 assert!(v.depth_local(DVec3::new(10.0, 9.5, 9.5)).is_none());
164 }
165
166 #[test]
167 fn depth_is_zero_at_surface_and_grows_downward() {
168 // z-down: the surface is the MIN-z face.
169 let v = WaterVolume::new(IVec3::new(0, 0, 10), IVec3::new(7, 7, 19));
170 assert_eq!(v.depth_local(DVec3::new(4.0, 4.0, 10.0)), Some(0.0));
171 assert_eq!(v.depth_local(DVec3::new(4.0, 4.0, 15.5)), Some(5.5));
172 // Above the surface (smaller z) is dry.
173 assert!(v.depth_local(DVec3::new(4.0, 4.0, 9.9)).is_none());
174 // Below the bottom is outside the volume, not "very deep".
175 assert!(v.depth_local(DVec3::new(4.0, 4.0, 20.0)).is_none());
176 }
177
178 // ---- Grid / Scene queries ----
179
180 #[test]
181 fn grid_takes_deepest_of_overlapping_volumes() {
182 let mut g = crate::Grid::new(GridTransform::identity());
183 // Two overlapping pools; the one with the HIGHER surface
184 // (smaller lo.z) reads deeper at a shared point.
185 g.add_water_volume(IVec3::new(0, 0, 10), IVec3::new(9, 9, 19));
186 g.add_water_volume(IVec3::new(0, 0, 14), IVec3::new(9, 9, 19));
187 assert_eq!(g.water_depth_local(DVec3::new(5.0, 5.0, 16.0)), Some(6.0));
188 }
189
190 #[test]
191 fn scene_world_query_identity_grid() {
192 let mut scene = Scene::new();
193 let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
194 scene
195 .grid_mut(id)
196 .unwrap()
197 .add_water_volume(IVec3::new(0, 0, 50), IVec3::new(31, 31, 63));
198 // World x=110 → local x=10; world z=55 → 5 under the surface.
199 assert_eq!(
200 scene.water_depth_at(DVec3::new(110.0, 5.0, 55.0)),
201 Some((id, 5.0))
202 );
203 assert!(scene.water_depth_at(DVec3::new(110.0, 5.0, 49.0)).is_none());
204 assert!(!scene.in_water(DVec3::new(0.0, 0.0, 55.0))); // outside grid
205 }
206
207 #[test]
208 fn scene_world_query_scales_with_vws() {
209 // SC discipline: a vws=0.5 grid's water is half as deep in
210 // world units, and the world waterline sits at origin.z +
211 // lo.z * vws.
212 let mut scene = Scene::new();
213 let id = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 0.5));
214 scene
215 .grid_mut(id)
216 .unwrap()
217 .add_water_volume(IVec3::new(0, 0, 20), IVec3::new(15, 15, 39));
218 // World z=10 is the surface (20 voxels × 0.5); 4 world units
219 // down = 8 voxels down.
220 assert_eq!(
221 scene.water_depth_at(DVec3::new(3.0, 3.0, 14.0)),
222 Some((id, 4.0))
223 );
224 assert!(scene.water_depth_at(DVec3::new(3.0, 3.0, 9.9)).is_none());
225 }
226
227 #[test]
228 fn scene_world_query_rotated_grid() {
229 // 90° yaw about z: world (x, y) → local (y, -x); z unchanged,
230 // so the surface stays world-horizontal under yaw.
231 let mut scene = Scene::new();
232 let rot = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2);
233 let id = scene.add_grid(GridTransform {
234 origin: DVec3::ZERO,
235 rotation: rot,
236 ..GridTransform::identity()
237 });
238 scene
239 .grid_mut(id)
240 .unwrap()
241 .add_water_volume(IVec3::new(0, 0, 8), IVec3::new(9, 19, 15));
242 // Local target (5, 10, 12) → world = rot * local.
243 let world = rot * DVec3::new(5.0, 10.0, 12.0);
244 assert_eq!(scene.water_depth_at(world), Some((id, 4.0)));
245 // A point that would be inside pre-rotation but isn't post.
246 assert!(scene.water_depth_at(DVec3::new(5.0, 10.0, 12.0)).is_none());
247 }
248
249 #[test]
250 fn swapped_corners_query_like_canonical_ones() {
251 // The fields are pub and easy to swap under z-down; a
252 // hand-built inverted volume must behave exactly like its
253 // canonical form — live AND across a snapshot round-trip
254 // (depth_local normalises, nothing else has to).
255 let swapped = WaterVolume {
256 lo: IVec3::new(7, 7, 19),
257 hi: IVec3::new(0, 0, 10),
258 };
259 let canonical = WaterVolume::new(IVec3::new(0, 0, 10), IVec3::new(7, 7, 19));
260 for p in [
261 DVec3::new(4.0, 4.0, 10.0),
262 DVec3::new(4.0, 4.0, 15.5),
263 DVec3::new(4.0, 4.0, 9.9),
264 DVec3::new(8.5, 4.0, 15.0),
265 ] {
266 assert_eq!(swapped.depth_local(p), canonical.depth_local(p));
267 }
268 }
269
270 #[test]
271 fn equal_depth_tie_breaks_to_smallest_grid_id() {
272 // Two identity grids sharing a waterline: the returned owner
273 // must be deterministic (grids() iterates a HashMap).
274 let mut scene = Scene::new();
275 let a = scene.add_grid(GridTransform::identity());
276 let b = scene.add_grid(GridTransform::identity());
277 for id in [a, b] {
278 scene
279 .grid_mut(id)
280 .unwrap()
281 .add_water_volume(IVec3::new(0, 0, 50), IVec3::new(31, 31, 63));
282 }
283 let winner = scene.water_depth_at(DVec3::new(5.0, 5.0, 55.0));
284 assert_eq!(winner, Some((a.min(b), 5.0)), "smallest GridId wins ties");
285 }
286
287 #[test]
288 fn no_volumes_means_dry_everywhere() {
289 let mut scene = Scene::new();
290 scene.add_grid(GridTransform::identity());
291 assert!(scene.water_depth_at(DVec3::new(1.0, 2.0, 3.0)).is_none());
292 assert!(!scene.in_water(DVec3::splat(50.0)));
293 }
294}