Skip to content

Commit 56e972a

Browse files
authored
Merge pull request #40 from tamirelazar/feat/per-preset-tone-curves
feat: per-preset tone curves via render/art-defaults payload (#32)
2 parents 4ca30b4 + afd13ec commit 56e972a

6 files changed

Lines changed: 202 additions & 12 deletions

File tree

src/app/mod.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,10 @@ pub fn print_mode(
537537
let background_color = config.background_color.as_ref().and_then(|c| hex_to_rgb(c));
538538

539539
let dither_mode = args.dither_mode().unwrap_or(DitherMode::None);
540-
let intensity_mapping = args.intensity_mapping().ok();
540+
let intensity_mapping = args
541+
.to_render_art_defaults()
542+
.ok()
543+
.map(|a| a.intensity_mapping);
541544

542545
let mut buffer = FrameBuffer::from_downsampled(
543546
downsampled.cells(),
@@ -719,7 +722,10 @@ pub fn capture_frames_mode(
719722
};
720723

721724
let background_color = config.background_color.as_ref().and_then(|c| hex_to_rgb(c));
722-
let intensity_mapping = args.intensity_mapping().ok();
725+
let intensity_mapping = args
726+
.to_render_art_defaults()
727+
.ok()
728+
.map(|a| a.intensity_mapping);
723729

724730
let opt_aux_frame = if temporal_strength > 0.0 {
725731
crate::render::downsample::downsample_aux(
@@ -952,7 +958,10 @@ pub fn export_gif_mode(
952958
};
953959

954960
let background_color = config.background_color.as_ref().and_then(|c| hex_to_rgb(c));
955-
let intensity_mapping = args.intensity_mapping().ok();
961+
let intensity_mapping = args
962+
.to_render_art_defaults()
963+
.ok()
964+
.map(|a| a.intensity_mapping);
956965

957966
let opt_aux_frame = if temporal_strength > 0.0 {
958967
crate::render::downsample::downsample_aux(
@@ -1123,7 +1132,10 @@ pub fn export_webm_mode(
11231132
};
11241133

11251134
let background_color = config.background_color.as_ref().and_then(|c| hex_to_rgb(c));
1126-
let intensity_mapping = args.intensity_mapping().ok();
1135+
let intensity_mapping = args
1136+
.to_render_art_defaults()
1137+
.ok()
1138+
.map(|a| a.intensity_mapping);
11271139

11281140
let opt_aux_frame = if temporal_strength > 0.0 {
11291141
crate::render::downsample::downsample_aux(

src/app/runner.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,9 @@ pub fn run_simulation(
243243
};
244244

245245
let initial_intensity_mapping = args
246-
.intensity_mapping()
247-
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
246+
.to_render_art_defaults()
247+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?
248+
.intensity_mapping;
248249

249250
renderer.set_intensity_mapping(Some(initial_intensity_mapping.clone()));
250251

src/cli.rs

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,11 +1050,11 @@ pub struct Args {
10501050
#[arg(
10511051
long = "intensity-mapping",
10521052
value_name = "MODE",
1053-
default_value = intensity_mapping::DEFAULT_TYPE,
1054-
help = "Intensity-to-color mapping (linear, log, exp, sqrt, square, sigmoid, smoothstep, quantize, perlin, split)"
1053+
help = "Intensity-to-color mapping (linear, log, exp, sqrt, square, sigmoid, smoothstep, quantize, perlin, split) [default: per-preset, currently log]"
10551054
)]
10561055
/// Intensity mapping mode for non-linear color distribution.
1057-
pub intensity_mapping: String,
1056+
/// `None` means "use the per-preset render default" (see `RenderArtDefaults`).
1057+
pub intensity_mapping: Option<String>,
10581058

10591059
#[arg(
10601060
long = "intensity-mapping-base",
@@ -1771,11 +1771,20 @@ impl Args {
17711771
}
17721772
}
17731773

1774+
/// The intensity-mapping type string, falling back to the historical
1775+
/// default when the flag is absent. Used by paths that always need a
1776+
/// concrete mapping (e.g. the pause-logo "sim" passthrough).
1777+
fn intensity_mapping_str(&self) -> &str {
1778+
self.intensity_mapping
1779+
.as_deref()
1780+
.unwrap_or(intensity_mapping::DEFAULT_TYPE)
1781+
}
1782+
17741783
/// Parses the intensity mapping configuration.
17751784
pub fn intensity_mapping(&self) -> Result<crate::render::palette::IntensityMapping, String> {
17761785
use crate::render::palette::{IntensityMapping, MappingFunction};
17771786

1778-
match self.intensity_mapping.to_lowercase().as_str() {
1787+
match self.intensity_mapping_str().to_lowercase().as_str() {
17791788
"linear" => Ok(IntensityMapping::linear()),
17801789
"log" | "logarithmic" => Ok(IntensityMapping::logarithmic(self.intensity_mapping_base)),
17811790
"exp" | "exponential" => Ok(IntensityMapping::exponential(self.intensity_mapping_base)),
@@ -1818,7 +1827,7 @@ impl Args {
18181827
)),
18191828
_ => Err(format!(
18201829
"Invalid intensity mapping: {}",
1821-
self.intensity_mapping
1830+
self.intensity_mapping_str()
18221831
)),
18231832
}
18241833
}
@@ -1950,6 +1959,23 @@ impl Args {
19501959
SimConfig::try_from(self)
19511960
}
19521961

1962+
/// Resolves the render-layer art defaults: per-preset defaults overridden
1963+
/// by explicit intensity-mapping CLI flags. Render counterpart to
1964+
/// [`Args::to_sim_config`] (spec §5 — render params stay out of `SimConfig`).
1965+
/// When no preset is set, falls back to `RenderArtDefaults::default()` (log10).
1966+
pub(crate) fn to_render_art_defaults(
1967+
&self,
1968+
) -> Result<crate::render_art_defaults::RenderArtDefaults, String> {
1969+
let mut art = match self.preset {
1970+
Some(preset) => crate::render_art_defaults::RenderArtDefaults::from(preset),
1971+
None => crate::render_art_defaults::RenderArtDefaults::default(),
1972+
};
1973+
if self.intensity_mapping.is_some() {
1974+
art.intensity_mapping = self.intensity_mapping()?;
1975+
}
1976+
Ok(art)
1977+
}
1978+
19531979
/// Validates arguments at the CLI boundary.
19541980
///
19551981
/// Covers terminal/resolution/fps bounds and other CLI-specific options that
@@ -2097,7 +2123,7 @@ impl Default for Args {
20972123
reverse_palette: false,
20982124
invert_palette: false,
20992125
palette_shift: 0.0,
2100-
intensity_mapping: intensity_mapping::DEFAULT_TYPE.to_string(),
2126+
intensity_mapping: None,
21012127
intensity_mapping_base: intensity::DEFAULT_LOG_BASE,
21022128
intensity_mapping_gamma: 2.2,
21032129
intensity_mapping_levels: 8,
@@ -2785,4 +2811,42 @@ mod tests {
27852811
);
27862812
assert!(DepositCurve::from_str("bogus").is_err());
27872813
}
2814+
2815+
#[test]
2816+
fn render_art_defaults_uses_preset_default_when_flag_absent() {
2817+
use crate::render::palette::IntensityMapping;
2818+
let args = Args {
2819+
preset: Some(crate::simulation::config::Preset::Vortex),
2820+
intensity_mapping: None,
2821+
..Default::default()
2822+
};
2823+
let art = args.to_render_art_defaults().unwrap();
2824+
// #32: every preset defaults to log10.
2825+
assert_eq!(art.intensity_mapping, IntensityMapping::logarithmic(10.0));
2826+
}
2827+
2828+
#[test]
2829+
fn render_art_defaults_cli_flag_overrides_preset() {
2830+
use crate::render::palette::IntensityMapping;
2831+
let args = Args {
2832+
preset: Some(crate::simulation::config::Preset::Vortex),
2833+
intensity_mapping: Some("linear".to_string()),
2834+
..Default::default()
2835+
};
2836+
let art = args.to_render_art_defaults().unwrap();
2837+
assert_eq!(art.intensity_mapping, IntensityMapping::linear());
2838+
}
2839+
2840+
#[test]
2841+
fn render_art_defaults_none_preset_falls_back_to_default() {
2842+
use crate::render::palette::IntensityMapping;
2843+
let args = Args {
2844+
preset: None,
2845+
intensity_mapping: None,
2846+
..Default::default()
2847+
};
2848+
let art = args.to_render_art_defaults().unwrap();
2849+
// No preset + no flag → RenderArtDefaults::default() == log10.
2850+
assert_eq!(art.intensity_mapping, IntensityMapping::logarithmic(10.0));
2851+
}
27882852
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ pub mod overlay;
7070
pub mod palette_manager;
7171
/// Rendering logic (ASCII/Unicode, color palettes, dithering).
7272
pub mod render;
73+
/// Per-preset render/art-layer defaults.
74+
pub(crate) mod render_art_defaults;
7375
/// Core simulation logic (agents, trail map).
7476
pub mod simulation;
7577
/// Terminal handling (input, output, raw mode).

src/render_art_defaults.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//! Per-preset render/art-layer defaults.
2+
//!
3+
//! `RenderArtDefaults` is the render-layer counterpart to [`SimConfig`]: it is
4+
//! resolved from the active [`Preset`] alongside the sim config, carrying render
5+
//! parameters (currently `intensity_mapping`) that must not pollute the sim layer.
6+
//! For now every preset uses the historical global default (log10); tasteful
7+
//! per-preset values land in the showcase-presets issue (#36).
8+
9+
use crate::render::palette::IntensityMapping;
10+
use crate::simulation::config::Preset;
11+
12+
/// Render-layer art defaults resolved per [`Preset`], emitted alongside
13+
/// [`crate::simulation::config::SimConfig`]. Keeps render concerns out of the
14+
/// sim layer (spec §5). Currently carries only `intensity_mapping`; later
15+
/// render-layer levers extend this struct.
16+
#[derive(Clone, Debug, PartialEq)]
17+
pub(crate) struct RenderArtDefaults {
18+
/// Brightness→color tone curve. Default = global log10 (historical default).
19+
pub intensity_mapping: IntensityMapping,
20+
}
21+
22+
impl Default for RenderArtDefaults {
23+
fn default() -> Self {
24+
Self {
25+
intensity_mapping: IntensityMapping::logarithmic(10.0),
26+
}
27+
}
28+
}
29+
30+
impl From<Preset> for RenderArtDefaults {
31+
/// Per-preset render defaults. For #32 every preset uses the historical
32+
/// global log10 so output is byte-identical; tasteful per-preset values
33+
/// are deferred to the showcase-presets issue (#36).
34+
fn from(_preset: Preset) -> Self {
35+
Self::default()
36+
}
37+
}
38+
39+
#[cfg(test)]
40+
mod tests {
41+
use super::*;
42+
use crate::render::palette::IntensityMapping;
43+
use crate::simulation::config::Preset;
44+
45+
#[test]
46+
fn default_is_log10() {
47+
assert_eq!(
48+
RenderArtDefaults::default().intensity_mapping,
49+
IntensityMapping::logarithmic(10.0)
50+
);
51+
}
52+
53+
#[test]
54+
fn every_preset_defaults_to_log10() {
55+
// Back-compat invariant for #32: payload is pure plumbing, so every
56+
// preset must resolve to today's global default. Per-preset tuning is #36.
57+
// NOTE: `Preset::ALL` does not exist in this codebase — list explicitly.
58+
let log10 = IntensityMapping::logarithmic(10.0);
59+
for preset in [
60+
Preset::Network,
61+
Preset::Exploratory,
62+
Preset::Tendrils,
63+
Preset::Organic,
64+
Preset::Minimal,
65+
Preset::Moss,
66+
Preset::Cosmic,
67+
Preset::Fire,
68+
Preset::Zen,
69+
Preset::Storm,
70+
Preset::River,
71+
Preset::Ethereal,
72+
Preset::PetriDish,
73+
Preset::Vortex,
74+
Preset::Lightning,
75+
Preset::Crystal,
76+
Preset::ChaosEdge,
77+
Preset::Blob,
78+
Preset::Worm,
79+
Preset::Pulse,
80+
Preset::Coral,
81+
Preset::Flocking,
82+
Preset::Maze,
83+
Preset::Ripple,
84+
Preset::Vortex36,
85+
Preset::Chameleon,
86+
Preset::DynamicTendrils,
87+
Preset::MorphingCoral,
88+
Preset::ReactiveSwarm,
89+
Preset::DuelingModulators,
90+
] {
91+
assert_eq!(
92+
RenderArtDefaults::from(preset).intensity_mapping,
93+
log10,
94+
"preset {preset:?} must default to log10 in #32"
95+
);
96+
}
97+
}
98+
}

tests/cli_integration.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,16 @@ fn test_cli_print_mode() {
6565
let stdout = String::from_utf8_lossy(&output.stdout);
6666
assert!(!stdout.is_empty());
6767
}
68+
69+
#[test]
70+
fn intensity_mapping_absent_is_none_present_is_some() {
71+
use clap::Parser;
72+
let bare = tslime::cli::Args::parse_from(["tslime"]);
73+
assert!(
74+
bare.intensity_mapping.is_none(),
75+
"bare run must leave flag unset"
76+
);
77+
78+
let explicit = tslime::cli::Args::parse_from(["tslime", "--intensity-mapping", "linear"]);
79+
assert_eq!(explicit.intensity_mapping.as_deref(), Some("linear"));
80+
}

0 commit comments

Comments
 (0)