symbios_tensor/tensor.rs
1//! Tensor field derived from heightmap surface normals.
2//!
3//! The field decomposes each surface normal into orthogonal 2D directions:
4//! a **major** (contour) axis that follows elevation lines and a **minor**
5//! (gradient) axis that points up- or down-slope. Road traces integrate
6//! along these axes to produce terrain-adaptive street layouts.
7//!
8//! On near-flat terrain the field smoothly blends toward an axis-aligned
9//! Manhattan grid as slope drops through `[flat_threshold_low,
10//! flat_threshold_high]`; an optional low-frequency jitter perturbs the
11//! field to break up perfectly parallel streamlines.
12
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use glam::{Vec2, Vec3};
16use serde::{Deserialize, Serialize};
17use symbios_ground::HeightMap;
18
19/// Configuration for [`TensorField`] sampling.
20///
21/// Slope (the magnitude of the normal's XZ projection) controls a smooth
22/// blend between terrain-derived directions and an axis-aligned fallback,
23/// avoiding the abrupt regime change at near-zero slopes that produced
24/// visible artefacts at the boundary.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TensorFieldConfig {
27 /// Slope at or below which the field returns the pure axis-aligned
28 /// fallback. Default: `1e-4`.
29 pub flat_threshold_low: f32,
30 /// Slope at or above which the field returns pure terrain-derived
31 /// directions. Default: `1e-3`.
32 pub flat_threshold_high: f32,
33 /// Amplitude of low-frequency directional jitter, in radians. Small
34 /// values (e.g. `0.05`–`0.2`) break perfectly parallel streamlines on
35 /// flat terrain. Default: `0.0` (disabled).
36 pub jitter_amplitude: f32,
37 /// Spatial frequency of the jitter (cycles per world unit). Lower
38 /// values produce larger, gentler swirls. Default: `0.01`.
39 pub jitter_frequency: f32,
40}
41
42impl Default for TensorFieldConfig {
43 fn default() -> Self {
44 Self {
45 flat_threshold_low: 1e-4,
46 flat_threshold_high: 1e-3,
47 jitter_amplitude: 0.0,
48 jitter_frequency: 0.01,
49 }
50 }
51}
52
53/// Evaluates a tensor field over a [`HeightMap`], producing orthogonal major/minor
54/// direction vectors at any world-space coordinate.
55///
56/// - **Major** (contour): follows elevation lines — ideal for winding mountain roads.
57/// - **Minor** (gradient): points up/down the slope — ideal for steep connecting streets.
58pub struct TensorField<'a> {
59 pub(crate) heightmap: &'a HeightMap,
60 config: TensorFieldConfig,
61 fallback_warned: AtomicBool,
62}
63
64impl<'a> TensorField<'a> {
65 /// Creates a tensor field with default sampling parameters.
66 pub fn new(heightmap: &'a HeightMap) -> Self {
67 Self::with_config(heightmap, TensorFieldConfig::default())
68 }
69
70 /// Creates a tensor field with custom sampling parameters.
71 pub fn with_config(heightmap: &'a HeightMap, config: TensorFieldConfig) -> Self {
72 Self {
73 heightmap,
74 config,
75 fallback_warned: AtomicBool::new(false),
76 }
77 }
78
79 /// Samples the tensor field, returning `(major, minor)` unit direction vectors.
80 pub fn sample(&self, world_x: f32, world_z: f32) -> (Vec2, Vec2) {
81 let n_arr = self.heightmap.get_normal_at(world_x, world_z);
82 let normal = Vec3::from_array(n_arr);
83
84 // Minor axis: projection of surface normal onto the XZ plane (gradient direction).
85 // Its magnitude is the slope (steepness).
86 let raw_minor = Vec2::new(normal.x, normal.z);
87 let slope = raw_minor.length();
88
89 let cfg = &self.config;
90 let t = smoothstep(cfg.flat_threshold_low, cfg.flat_threshold_high, slope);
91
92 if t < 1.0
93 && self
94 .fallback_warned
95 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
96 .is_ok()
97 {
98 eprintln!(
99 "symbios-tensor: tensor field blending toward axis-aligned fallback on near-flat terrain (slope={slope:.3e})"
100 );
101 }
102
103 // Stable minor direction. On true-zero slope, default to +Z; otherwise
104 // blend the terrain direction toward the nearest axis as t→0 to avoid
105 // sign-flip artefacts when the gradient direction is unstable.
106 let blended_minor = if slope > 1e-12 {
107 let terrain_dir = raw_minor / slope;
108 let fallback_dir = nearest_axis(terrain_dir);
109 (terrain_dir * t + fallback_dir * (1.0 - t)).normalize_or_zero()
110 } else {
111 Vec2::new(0.0, 1.0)
112 };
113
114 // Apply low-frequency directional jitter (deterministic per world point).
115 let minor = if cfg.jitter_amplitude.abs() > 0.0 {
116 let phase = world_x * cfg.jitter_frequency + world_z * cfg.jitter_frequency * 1.7320508;
117 let angle = cfg.jitter_amplitude * phase.sin();
118 rotate(blended_minor, angle)
119 } else {
120 blended_minor
121 };
122
123 let minor = if minor.length_squared() < 1e-12 {
124 Vec2::new(0.0, 1.0)
125 } else {
126 minor.normalize()
127 };
128 let major = Vec2::new(-minor.y, minor.x);
129 (major, minor)
130 }
131}
132
133fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
134 if edge1 <= edge0 {
135 return if x >= edge1 { 1.0 } else { 0.0 };
136 }
137 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
138 t * t * (3.0 - 2.0 * t)
139}
140
141/// Returns the unit axis vector (`±X` or `±Z`) closest to `dir`. Caller
142/// must pass a non-zero direction.
143fn nearest_axis(dir: Vec2) -> Vec2 {
144 if dir.x.abs() >= dir.y.abs() {
145 Vec2::new(if dir.x >= 0.0 { 1.0 } else { -1.0 }, 0.0)
146 } else {
147 Vec2::new(0.0, if dir.y >= 0.0 { 1.0 } else { -1.0 })
148 }
149}
150
151fn rotate(v: Vec2, angle: f32) -> Vec2 {
152 let (s, c) = angle.sin_cos();
153 Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn flat_terrain_consistent_direction() {
162 // On a perfectly flat heightmap every sample must return the same
163 // axis-aligned direction (no split between two regimes).
164 let hm = HeightMap::new(64, 64, 2.0);
165 let field = TensorField::new(&hm);
166
167 let (m0, n0) = field.sample(10.0, 10.0);
168 for x in (0..120).step_by(7) {
169 for z in (0..120).step_by(11) {
170 let (m, n) = field.sample(x as f32, z as f32);
171 assert!(
172 (m - m0).length() < 1e-5 && (n - n0).length() < 1e-5,
173 "flat-terrain sample at ({x}, {z}) drifted: major={m:?} expected {m0:?}, minor={n:?} expected {n0:?}"
174 );
175 }
176 }
177 }
178
179 #[test]
180 fn smooth_blend_no_cliff() {
181 // Build a heightmap with a controlled slope and verify the blend
182 // function returns directions that smoothly transition rather than
183 // snapping at a single threshold.
184 let mut hm = HeightMap::new(32, 32, 1.0);
185 // Very gentle slope: 0.0005 * x — slope magnitude ~0.0005, in the
186 // middle of the default blend window (1e-4 .. 1e-3).
187 for z in 0..32 {
188 for x in 0..32 {
189 hm.set(x, z, x as f32 * 0.0005);
190 }
191 }
192 let field = TensorField::new(&hm);
193 let (_major, minor) = field.sample(15.0, 15.0);
194 // Under the old binary fallback the result would be either the pure
195 // terrain direction or the fallback. Under smooth blend, the minor
196 // direction is a blend that points roughly along +X (the slope) but
197 // is biased toward an axis. Just assert it's a valid unit vector.
198 assert!(
199 (minor.length() - 1.0).abs() < 1e-4,
200 "minor must be unit, got {minor:?}"
201 );
202 }
203
204 #[test]
205 fn jitter_breaks_flat_uniformity() {
206 // With jitter enabled on a perfectly flat heightmap, samples at
207 // different world points must yield different directions, breaking
208 // up the perfectly parallel streamlines that flat terrain would
209 // otherwise produce.
210 let hm = HeightMap::new(64, 64, 2.0);
211 let cfg = TensorFieldConfig {
212 jitter_amplitude: 0.3,
213 jitter_frequency: 0.05,
214 ..Default::default()
215 };
216 let field = TensorField::with_config(&hm, cfg);
217
218 let (_, n0) = field.sample(10.0, 10.0);
219 let (_, n1) = field.sample(50.0, 50.0);
220 assert!(
221 (n0 - n1).length() > 1e-3,
222 "jitter must produce distinct directions; got {n0:?} and {n1:?}"
223 );
224 }
225
226 #[test]
227 fn major_minor_orthogonal() {
228 let mut hm = HeightMap::new(16, 16, 1.0);
229 for z in 0..16 {
230 for x in 0..16 {
231 hm.set(x, z, (x + z) as f32 * 0.1);
232 }
233 }
234 let field = TensorField::new(&hm);
235 let (m, n) = field.sample(8.0, 8.0);
236 assert!(
237 m.dot(n).abs() < 1e-4,
238 "major and minor must be orthogonal: m={m:?}, n={n:?}, dot={}",
239 m.dot(n)
240 );
241 }
242}