Skip to main content

agent/orbs/engine/
profiles.rs

1//! Density profiles + the multiplier machinery that scales them.
2
3/// Hard ceilings for count-like knobs. Preset values sit well below these;
4/// they only bite adversarial or mistaken power-user inputs.
5pub const MAX_LATTICE_RINGS: f32 = 128.0;
6pub const MAX_LON_DENSITY: f32 = 256.0;
7pub const MAX_ORBIT_N: f32 = 64.0;
8pub const MAX_GHOST_N: f32 = 512.0;
9pub const MAX_PARTICLES: f32 = 16.0;
10pub const MAX_NODE_N: f32 = 128.0;
11pub const MAX_SIGNALS: f32 = 64.0;
12pub const MAX_STRAND_N: f32 = 256.0;
13pub const MAX_LANES: f32 = 32.0;
14pub const MAX_SEGS: f32 = 512.0;
15pub const MAX_MOVE_COUNT: f32 = 64.0;
16pub const MAX_ICON_D: f32 = 8.0;
17/// Max dots the Morph painter will emit after `icon_d` is applied.
18pub const MAX_MORPH_DOTS: usize = 512;
19/// Logical size (px) accepted by [`crate::orbs::engine::draw_mode_into`].
20pub const MIN_SIZE: f32 = 1.0;
21pub const MAX_SIZE: f32 = 1024.0;
22
23/// Free-form numeric knobs for mode painters (mirrors upstream `ModeOpts`).
24///
25/// **Power-user / advanced API.** The normal path is [`crate::orbs::Orb`] /
26/// [`crate::orbs::resolve_preset`], which only ever pass hand-tuned finite values.
27///
28/// If you build a [`ModeOpts`] yourself and pass it to
29/// [`crate::orbs::engine::draw_mode`] / [`crate::orbs::engine::draw_mode_into`], every count-like field is
30/// clamped by [`sanitize_mode_opts`] before geometry runs:
31///
32/// | Field family | Range after sanitize |
33/// |--------------|----------------------|
34/// | lattice rings / density | 1…128 / 1…256 |
35/// | `orbit_n`, `ghost_n`, `particles` | 0…64 / 0…512 / 0…16 |
36/// | `node_n`, `signals` | 0…128 / 0…64 |
37/// | `strand_n`, `lanes`, `segs` | 0…256 / 1…32 / 1…512 |
38/// | `move_count` | 0…64 |
39/// | `icon_d` | 0.02…8 |
40///
41/// Non-finite values fall back to the field default used by the painter.
42/// New fields may be added without a major version bump.
43#[derive(Clone, Debug, Default)]
44#[non_exhaustive]
45pub struct ModeOpts {
46    // lattice
47    pub lat_rings: Option<f32>,
48    pub lon_density: Option<f32>,
49    pub rings: Option<f32>,
50    pub r_base: Option<f32>,
51    pub r_depth: Option<f32>,
52    pub r_boost: Option<f32>,
53    pub r_active: Option<f32>,
54    pub ink_far: Option<f32>,
55    pub ink_span: Option<f32>,
56    pub rs_pow: Option<f32>,
57    pub r_min: Option<f32>,
58    pub move_count: Option<f32>,
59    pub scan_mul: Option<f32>,
60    pub dim_base: Option<f32>,
61    // orbits
62    pub orbit_n: Option<f32>,
63    pub ghost_n: Option<f32>,
64    pub ghost_r: Option<f32>,
65    pub ghost_a: Option<f32>,
66    pub particles: Option<f32>,
67    pub part_r: Option<f32>,
68    pub part_r_depth: Option<f32>,
69    // web
70    pub node_n: Option<f32>,
71    pub thr: Option<f32>,
72    pub signals: Option<f32>,
73    pub node_r: Option<f32>,
74    pub node_r_depth: Option<f32>,
75    pub line_w: Option<f32>,
76    // braid
77    pub strand_n: Option<f32>,
78    pub turns: Option<f32>,
79    // ribbon / ring
80    pub lanes: Option<f32>,
81    pub segs: Option<f32>,
82    pub face_on: Option<f32>,
83    pub spin: Option<f32>,
84    pub band_mul: Option<f32>,
85    pub wob_mul: Option<f32>,
86    // morph
87    pub r_dot: Option<f32>,
88    pub icon_d: Option<f32>,
89    pub spread: Option<f32>,
90    pub r_size_mul: Option<f32>,
91}
92
93/// Clamp a free-form float option into a finite range, or leave `None`.
94fn clamp_opt(v: Option<f32>, min: f32, max: f32) -> Option<f32> {
95    v.and_then(|x| {
96        if x.is_finite() {
97            Some(x.clamp(min, max))
98        } else {
99            None
100        }
101    })
102}
103
104/// Return a safe copy of `opts` with every count-like / radius-like knob
105/// finite and within the hard ceilings above.
106///
107/// Called automatically by [`crate::orbs::engine::draw_mode_into`]. Safe to call yourself
108/// when composing custom profiles.
109pub fn sanitize_mode_opts(opts: &ModeOpts) -> ModeOpts {
110    ModeOpts {
111        lat_rings: clamp_opt(opts.lat_rings, 1.0, MAX_LATTICE_RINGS),
112        lon_density: clamp_opt(opts.lon_density, 1.0, MAX_LON_DENSITY),
113        rings: clamp_opt(opts.rings, 1.0, MAX_LATTICE_RINGS),
114        r_base: clamp_opt(opts.r_base, 0.0, 64.0),
115        r_depth: clamp_opt(opts.r_depth, 0.0, 64.0),
116        r_boost: clamp_opt(opts.r_boost, 0.0, 64.0),
117        r_active: clamp_opt(opts.r_active, 0.0, 64.0),
118        ink_far: clamp_opt(opts.ink_far, 0.0, 1.0),
119        ink_span: clamp_opt(opts.ink_span, 0.0, 2.0),
120        rs_pow: clamp_opt(opts.rs_pow, 0.05, 4.0),
121        r_min: clamp_opt(opts.r_min, 0.0, 32.0),
122        move_count: clamp_opt(opts.move_count, 0.0, MAX_MOVE_COUNT),
123        scan_mul: clamp_opt(opts.scan_mul, 0.0, 16.0),
124        dim_base: clamp_opt(opts.dim_base, 0.0, 1.0),
125        orbit_n: clamp_opt(opts.orbit_n, 0.0, MAX_ORBIT_N),
126        ghost_n: clamp_opt(opts.ghost_n, 0.0, MAX_GHOST_N),
127        ghost_r: clamp_opt(opts.ghost_r, 0.0, 64.0),
128        ghost_a: clamp_opt(opts.ghost_a, 0.0, 1.0),
129        particles: clamp_opt(opts.particles, 0.0, MAX_PARTICLES),
130        part_r: clamp_opt(opts.part_r, 0.0, 64.0),
131        part_r_depth: clamp_opt(opts.part_r_depth, 0.0, 64.0),
132        node_n: clamp_opt(opts.node_n, 0.0, MAX_NODE_N),
133        thr: clamp_opt(opts.thr, 0.0, 4.0),
134        signals: clamp_opt(opts.signals, 0.0, MAX_SIGNALS),
135        node_r: clamp_opt(opts.node_r, 0.0, 64.0),
136        node_r_depth: clamp_opt(opts.node_r_depth, 0.0, 64.0),
137        line_w: clamp_opt(opts.line_w, 0.0, 32.0),
138        strand_n: clamp_opt(opts.strand_n, 0.0, MAX_STRAND_N),
139        turns: clamp_opt(opts.turns, 0.0, 32.0),
140        lanes: clamp_opt(opts.lanes, 1.0, MAX_LANES),
141        segs: clamp_opt(opts.segs, 1.0, MAX_SEGS),
142        face_on: clamp_opt(opts.face_on, 0.0, 1.0),
143        spin: clamp_opt(opts.spin, -16.0, 16.0),
144        band_mul: clamp_opt(opts.band_mul, 0.05, 8.0),
145        wob_mul: clamp_opt(opts.wob_mul, 0.0, 8.0),
146        r_dot: clamp_opt(opts.r_dot, 0.0, 1.0),
147        icon_d: clamp_opt(opts.icon_d, 0.02, MAX_ICON_D),
148        spread: clamp_opt(opts.spread, 0.05, 4.0),
149        r_size_mul: clamp_opt(opts.r_size_mul, 0.0, 64.0),
150    }
151}
152
153/// Clamp the logical paint size in pixels.
154pub fn sanitize_size(size: f32) -> f32 {
155    if size.is_finite() {
156        size.clamp(MIN_SIZE, MAX_SIZE)
157    } else {
158        64.0
159    }
160}
161
162/// Interpret a count-like option as `usize` after sanitization has run.
163///
164/// Prefer calling this on already-sanitized opts. Non-finite / missing use
165/// `default`; the result is always in `min..=max`.
166#[inline]
167pub fn count_usize(v: Option<f32>, default: f32, min: usize, max: usize) -> usize {
168    let x = match v {
169        Some(x) if x.is_finite() => x,
170        _ => default,
171    };
172    let lo = min as f32;
173    let hi = max as f32;
174    (x.round().clamp(lo, hi)) as usize
175}
176
177/// Scale total dot density. 2-D lattices (rings × dots-per-ring) take √scale
178/// each side so the TOTAL scales by `scale`. Flat lists scale linearly.
179pub fn scale_counts(opts: &ModeOpts, scale: f32) -> ModeOpts {
180    let mut out = opts.clone();
181    let scale = if scale.is_finite() && scale > 0.0 {
182        scale
183    } else {
184        1.0
185    };
186    let rt = scale.sqrt();
187
188    // Upstream COUNT_PAIRS: latRings×lonDensity first (owns lonDensity),
189    // then rings×lonDensity only if latRings was absent.
190    if let (Some(va), Some(vb)) = (out.lat_rings, out.lon_density) {
191        out.lat_rings = Some((va * rt).round().clamp(2.0, MAX_LATTICE_RINGS));
192        out.lon_density = Some((vb * rt).round().clamp(2.0, MAX_LON_DENSITY));
193    } else if let (Some(va), Some(vb)) = (out.rings, out.lon_density) {
194        out.rings = Some((va * rt).round().clamp(2.0, MAX_LATTICE_RINGS));
195        out.lon_density = Some((vb * rt).round().clamp(2.0, MAX_LON_DENSITY));
196    }
197    // lanes × segs pair
198    if let (Some(va), Some(vb)) = (out.lanes, out.segs) {
199        out.lanes = Some((va * rt).round().clamp(2.0, MAX_LANES));
200        out.segs = Some((vb * rt).round().clamp(2.0, MAX_SEGS));
201    }
202
203    // linear count keys (0 means opt-out — do not resurrect)
204    let scale_key = |v: Option<f32>, max: f32| match v {
205        Some(0.0) => Some(0.0),
206        Some(v) if v.is_finite() => Some((v * scale).round().clamp(1.0, max)),
207        Some(_) => None,
208        None => None,
209    };
210    out.orbit_n = scale_key(out.orbit_n, MAX_ORBIT_N);
211    out.ghost_n = scale_key(out.ghost_n, MAX_GHOST_N);
212    out.node_n = scale_key(out.node_n, MAX_NODE_N);
213    out.strand_n = scale_key(out.strand_n, MAX_STRAND_N);
214    out.signals = scale_key(out.signals, MAX_SIGNALS);
215
216    if let Some(v) = out.icon_d {
217        if v.is_finite() {
218            out.icon_d = Some((v * scale).clamp(0.02, MAX_ICON_D));
219        } else {
220            out.icon_d = None;
221        }
222    }
223    out
224}
225
226/// Scale every key that sets a dot's rendered radius.
227pub fn scale_radii(opts: &ModeOpts, scale: f32) -> ModeOpts {
228    let mut out = opts.clone();
229    let scale = if scale.is_finite() { scale } else { 1.0 };
230    let mul = |v: Option<f32>| v.and_then(|x| x.is_finite().then_some(x * scale));
231    out.r_base = mul(out.r_base);
232    out.r_depth = mul(out.r_depth);
233    out.r_active = mul(out.r_active);
234    out.r_dot = mul(out.r_dot);
235    out.ghost_r = mul(out.ghost_r);
236    out.part_r = mul(out.part_r);
237    out.part_r_depth = mul(out.part_r_depth);
238    out.node_r = mul(out.node_r);
239    out.node_r_depth = mul(out.node_r_depth);
240    out.r_size_mul = Some(out.r_size_mul.unwrap_or(1.0) * scale);
241    out
242}
243
244impl ModeOpts {
245    /// The base profile for `mode`, then hand the mutable options over for the
246    /// power-user tweaks a preset cannot express. The struct is
247    /// `#[non_exhaustive]`, so this is the only way to build one outside the
248    /// crate.
249    pub fn fill(mode: crate::orbs::types::ModeKey, f: impl FnOnce(&mut Self)) -> Self {
250        let mut opts = base_profile(mode);
251        f(&mut opts);
252        opts
253    }
254}
255
256/// Base (fine) profiles per mode, before preset multipliers.
257pub fn base_profile(mode: crate::orbs::types::ModeKey) -> ModeOpts {
258    use crate::orbs::types::ModeKey::*;
259    match mode {
260        Globe => ModeOpts {
261            lat_rings: Some(17.0),
262            lon_density: Some(44.0),
263            r_base: Some(0.6),
264            r_depth: Some(1.7),
265            r_boost: Some(1.0),
266            ink_far: Some(0.62),
267            ink_span: Some(0.54),
268            rs_pow: Some(0.6),
269            r_min: Some(0.3),
270            ..Default::default()
271        },
272        Orbits => ModeOpts {
273            orbit_n: Some(12.0),
274            ghost_n: Some(40.0),
275            ghost_r: Some(0.9),
276            ghost_a: Some(0.5),
277            particles: Some(3.0),
278            part_r: Some(1.2),
279            part_r_depth: Some(1.6),
280            rs_pow: Some(0.6),
281            r_min: Some(0.3),
282            ..Default::default()
283        },
284        Rubik => ModeOpts {
285            lat_rings: Some(15.0),
286            lon_density: Some(40.0),
287            move_count: Some(14.0),
288            r_base: Some(0.6),
289            r_depth: Some(1.7),
290            r_active: Some(0.3),
291            ink_far: Some(0.62),
292            ink_span: Some(0.54),
293            rs_pow: Some(0.6),
294            r_min: Some(0.3),
295            ..Default::default()
296        },
297        Wave => ModeOpts {
298            rings: Some(15.0),
299            lon_density: Some(40.0),
300            r_base: Some(0.6),
301            r_depth: Some(1.7),
302            rs_pow: Some(0.6),
303            r_min: Some(0.3),
304            ..Default::default()
305        },
306        Web => ModeOpts {
307            node_n: Some(30.0),
308            thr: Some(0.72),
309            signals: Some(5.0),
310            node_r: Some(1.4),
311            node_r_depth: Some(1.8),
312            line_w: Some(0.8),
313            rs_pow: Some(0.6),
314            r_min: Some(0.3),
315            ..Default::default()
316        },
317        Braid => ModeOpts {
318            strand_n: Some(52.0),
319            turns: Some(3.0),
320            ghost_n: Some(150.0),
321            r_base: Some(1.2),
322            r_depth: Some(1.8),
323            rs_pow: Some(0.6),
324            r_min: Some(0.3),
325            ..Default::default()
326        },
327        Ribbon => ModeOpts {
328            lanes: Some(5.0),
329            segs: Some(88.0),
330            ghost_n: Some(150.0),
331            r_base: Some(1.1),
332            r_depth: Some(1.7),
333            rs_pow: Some(0.6),
334            r_min: Some(0.3),
335            ..Default::default()
336        },
337        Ring => ModeOpts {
338            lanes: Some(5.0),
339            segs: Some(88.0),
340            ghost_n: Some(0.0),
341            face_on: Some(1.0),
342            r_base: Some(1.1),
343            r_depth: Some(1.7),
344            rs_pow: Some(0.6),
345            r_min: Some(0.3),
346            ..Default::default()
347        },
348        Morph => ModeOpts {
349            r_dot: Some(0.021),
350            icon_d: Some(1.0),
351            r_min: Some(0.25),
352            ..Default::default()
353        },
354        Focus => ModeOpts {
355            lanes: Some(6.0),
356            segs: Some(12.0),
357            particles: Some(5.0),
358            r_base: Some(0.9),
359            r_depth: Some(1.25),
360            rs_pow: Some(0.6),
361            r_min: Some(0.3),
362            ..Default::default()
363        },
364        Gyroscope => ModeOpts {
365            lanes: Some(3.0),
366            segs: Some(28.0),
367            r_base: Some(0.8),
368            r_depth: Some(1.5),
369            rs_pow: Some(0.6),
370            r_min: Some(0.3),
371            ..Default::default()
372        },
373        Echo => ModeOpts {
374            lanes: Some(4.0),
375            segs: Some(18.0),
376            particles: Some(3.0),
377            r_base: Some(0.85),
378            r_depth: Some(1.05),
379            rs_pow: Some(0.6),
380            r_min: Some(0.3),
381            ..Default::default()
382        },
383    }
384}