use std::f64::consts::{FRAC_PI_2, PI};
use noise::{NoiseFn, Perlin};
use crate::{
generator::{TextureError, TextureGenerator, TextureMap, linear_to_srgb, validate_dimensions},
leaf::{LeafConfig, LeafSampler},
normal::{BoundaryMode, height_to_normal},
};
const STEM_CURVE_FREQ: f64 = 1.8;
const STEM_PERLIN_SEED_OFFSET: u32 = 77;
const STEM_PERLIN_Y: f64 = 13.7;
const SYMPODIAL_ZZ_SCALE: f64 = 1.5;
const STEM_TAPER_POW: f64 = 0.55;
const INTERNODE_WIDTH: f64 = 0.45;
const TERMINAL_SCALE: f64 = 0.72;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct TwigConfig {
pub leaf: LeafConfig,
pub stem_color: [f32; 3],
pub stem_half_width: f64,
pub leaf_pairs: usize,
pub leaf_angle: f64,
pub leaf_scale: f64,
pub stem_curve: f64,
pub sympodial: bool,
}
impl Default for TwigConfig {
fn default() -> Self {
Self {
leaf: LeafConfig::default(),
stem_color: [0.18, 0.08, 0.06],
stem_half_width: 0.021,
leaf_pairs: 4,
leaf_angle: FRAC_PI_2 - 0.35, leaf_scale: 0.38,
stem_curve: 0.015,
sympodial: true,
}
}
}
pub struct LeafAttachment {
pub attach_u: f64,
pub attach_v: f64,
pub angle: f64,
pub scale: f64,
}
pub struct TwigGenerator {
config: TwigConfig,
}
impl TwigGenerator {
pub fn new(config: TwigConfig) -> Self {
Self { config }
}
pub fn leaf_attachments(&self, stem_perlin: &Perlin) -> Vec<LeafAttachment> {
let c = &self.config;
let n = c.leaf_pairs.max(1);
if c.sympodial {
self.sympodial_attachments(n, stem_perlin)
} else {
self.monopodial_attachments(n, stem_perlin)
}
}
fn monopodial_attachments(&self, n: usize, perlin: &Perlin) -> Vec<LeafAttachment> {
let c = &self.config;
let mut atts = Vec::with_capacity(n * 2 + 1);
let term_v = terminal_v(c);
let term_tangent = stem_tangent_at(term_v, c, perlin);
atts.push(LeafAttachment {
attach_u: stem_center_u(term_v, c, perlin),
attach_v: term_v,
angle: term_tangent + PI, scale: c.leaf_scale * TERMINAL_SCALE,
});
let lat_start = term_v + 0.05;
let lat_span = 0.88 - lat_start;
for i in 0..n {
let attach_v = lat_start + (i as f64 / n as f64) * lat_span;
let attach_u = stem_center_u(attach_v, c, perlin);
let tangent = stem_tangent_at(attach_v, c, perlin);
atts.push(LeafAttachment {
attach_u,
attach_v,
angle: tangent + c.leaf_angle, scale: c.leaf_scale,
});
atts.push(LeafAttachment {
attach_u,
attach_v,
angle: tangent - c.leaf_angle, scale: c.leaf_scale,
});
}
atts
}
fn sympodial_attachments(&self, n: usize, perlin: &Perlin) -> Vec<LeafAttachment> {
let c = &self.config;
let mut atts = Vec::with_capacity(n + 1);
let term_v = terminal_v(c);
let term_tangent = stem_tangent_at(term_v, c, perlin);
atts.push(LeafAttachment {
attach_u: stem_center_u(term_v, c, perlin),
attach_v: term_v,
angle: term_tangent + PI,
scale: c.leaf_scale * TERMINAL_SCALE,
});
let lat_start = term_v + 0.05;
let lat_span = 0.88 - lat_start;
for i in 0..n {
let k = i as f64;
let normalized = (2.0 * k + 1.0) / (2.0 * n as f64);
let attach_v = lat_start + normalized * lat_span;
let attach_u = stem_center_u(attach_v, c, perlin);
let tangent = stem_tangent_at(attach_v, c, perlin);
let side = if i % 2 == 0 { 1.0_f64 } else { -1.0 };
atts.push(LeafAttachment {
attach_u,
attach_v,
angle: tangent + side * c.leaf_angle,
scale: c.leaf_scale,
});
}
atts
}
}
impl TextureGenerator for TwigGenerator {
fn generate(&self, width: u32, height: u32) -> Result<TextureMap, TextureError> {
validate_dimensions(width, height)?;
let c = &self.config;
let stem_perlin = Perlin::new(c.leaf.seed.wrapping_add(STEM_PERLIN_SEED_OFFSET));
let sampler = LeafSampler::new(c.leaf.clone());
let attachments = self.leaf_attachments(&stem_perlin);
let w = width as usize;
let h = height as usize;
let n = w * h;
let term_v = terminal_v(c);
let node_vs = leaf_node_vs(c);
let mut heights = vec![0.5f64; n];
let mut albedo = vec![0u8; n * 4];
let mut roughness = vec![0u8; n * 4];
for y in 0..h {
let pv = (y as f64 + 0.5) / h as f64;
let s_center = stem_center_u(pv, c, &stem_perlin);
let s_hw = stem_half_width_at(pv, c.stem_half_width);
let seg_f = stem_segment_factor(pv, &node_vs);
let eff_hw = s_hw * seg_f;
for x in 0..w {
let pu = (x as f64 + 0.5) / w as f64;
let idx = y * w + x;
let ai = idx * 4;
let dist_to_stem = (pu - s_center).abs();
let mut hit = false;
for att in &attachments {
let (lu, lv) = pixel_to_leaf_uv(pu, pv, att);
if !(0.0..=1.0).contains(&lu) || !(0.0..=1.0).contains(&lv) {
continue;
}
if let Some(s) = sampler.sample(lu, lv) {
heights[idx] = s.height;
albedo[ai] = linear_to_srgb(s.color[0]);
albedo[ai + 1] = linear_to_srgb(s.color[1]);
albedo[ai + 2] = linear_to_srgb(s.color[2]);
albedo[ai + 3] = 255;
roughness[ai] = 255;
roughness[ai + 1] = (s.roughness * 255.0).round() as u8;
roughness[ai + 2] = 0;
roughness[ai + 3] = 255;
hit = true;
break;
}
}
if hit {
continue;
}
if s_hw > 1e-9 && dist_to_stem < eff_hw && pv >= term_v {
let t = (1.0 - dist_to_stem / eff_hw) as f32;
heights[idx] = t as f64 * seg_f * 0.8;
albedo[ai] = linear_to_srgb(lerp(c.stem_color[0] * 0.55, c.stem_color[0], t));
albedo[ai + 1] =
linear_to_srgb(lerp(c.stem_color[1] * 0.55, c.stem_color[1], t));
albedo[ai + 2] =
linear_to_srgb(lerp(c.stem_color[2] * 0.55, c.stem_color[2], t));
albedo[ai + 3] = 255;
roughness[ai] = 255;
roughness[ai + 1] = (0.78_f32 * 255.0) as u8;
roughness[ai + 2] = 0;
roughness[ai + 3] = 255;
} else {
let ec = &c.leaf.color_edge;
albedo[ai] = linear_to_srgb(ec[0]);
albedo[ai + 1] = linear_to_srgb(ec[1]);
albedo[ai + 2] = linear_to_srgb(ec[2]);
albedo[ai + 3] = 0;
roughness[ai] = 255;
roughness[ai + 1] = 200;
roughness[ai + 2] = 0;
roughness[ai + 3] = 255;
}
}
}
crate::normal::dilate_heights(&mut heights, &albedo, w, h);
let normal = height_to_normal(
&heights,
width,
height,
c.leaf.normal_strength,
BoundaryMode::Clamp,
);
Ok(TextureMap {
albedo,
normal,
roughness,
width,
height,
mip_level_count: 1,
emissive: None,
})
}
}
#[inline]
fn terminal_v(config: &TwigConfig) -> f64 {
config.leaf_scale * TERMINAL_SCALE + 0.03
}
fn stem_center_u(pv: f64, config: &TwigConfig, perlin: &Perlin) -> f64 {
let organic = (perlin.get([pv * STEM_CURVE_FREQ, STEM_PERLIN_Y])
- perlin.get([STEM_CURVE_FREQ, STEM_PERLIN_Y]))
* config.stem_curve;
let zigzag = if config.sympodial {
let lat_start = terminal_v(config) + 0.05;
let lat_span = 0.88 - lat_start;
if lat_span > 0.0 {
let phase = (pv - lat_start) / lat_span * config.leaf_pairs as f64 * PI;
let phase_base = (1.0 - lat_start) / lat_span * config.leaf_pairs as f64 * PI;
(phase.sin() * pv - phase_base.sin()) * config.stem_curve * SYMPODIAL_ZZ_SCALE
} else {
0.0
}
} else {
0.0
};
(0.5 + organic + zigzag).clamp(0.08, 0.92)
}
fn stem_half_width_at(pv: f64, half_width: f64) -> f64 {
half_width * pv.powf(STEM_TAPER_POW)
}
fn leaf_node_vs(config: &TwigConfig) -> Vec<f64> {
let term_v = terminal_v(config);
let lat_start = term_v + 0.05;
let lat_span = 0.88 - lat_start;
let n = config.leaf_pairs.max(1);
let mut vs = Vec::with_capacity(n + 1);
vs.push(term_v);
for i in 0..n {
let k = i as f64;
let normalized = if config.sympodial {
(2.0 * k + 1.0) / (2.0 * n as f64)
} else {
k / n as f64
};
vs.push(lat_start + normalized * lat_span);
}
vs
}
fn stem_segment_factor(pv: f64, node_vs: &[f64]) -> f64 {
let n = node_vs.len();
for i in 0..n.saturating_sub(1) {
if pv <= node_vs[i + 1] + 1e-9 {
let t = ((pv - node_vs[i]) / (node_vs[i + 1] - node_vs[i])).clamp(0.0, 1.0);
let c = (t * PI).cos();
return INTERNODE_WIDTH + (1.0 - INTERNODE_WIDTH) * c * c;
}
}
1.0
}
fn stem_tangent_at(pv: f64, config: &TwigConfig, perlin: &Perlin) -> f64 {
let delta = 0.005_f64;
let (u_lo, u_hi, dv) = if pv < delta {
(
stem_center_u(pv, config, perlin),
stem_center_u(pv + delta, config, perlin),
delta,
)
} else if pv > 1.0 - delta {
(
stem_center_u(pv - delta, config, perlin),
stem_center_u(pv, config, perlin),
delta,
)
} else {
(
stem_center_u(pv - delta, config, perlin),
stem_center_u(pv + delta, config, perlin),
2.0 * delta,
)
};
let du_dv = (u_hi - u_lo) / dv;
du_dv.atan2(1.0)
}
fn pixel_to_leaf_uv(pu: f64, pv: f64, att: &LeafAttachment) -> (f64, f64) {
let dx = pu - att.attach_u;
let dy = pv - att.attach_v;
let cos_a = att.angle.cos();
let sin_a = att.angle.sin();
let u_raw = dx * cos_a - dy * sin_a;
let v_raw = dx * sin_a + dy * cos_a;
(u_raw / att.scale + 0.5, v_raw / att.scale)
}
#[inline]
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_stem_perlin(config: &TwigConfig) -> Perlin {
Perlin::new(config.leaf.seed.wrapping_add(STEM_PERLIN_SEED_OFFSET))
}
#[test]
fn monopodial_attachment_count() {
let config = TwigConfig {
sympodial: false,
..TwigConfig::default()
};
let twig_gen = TwigGenerator::new(config.clone());
let atts = twig_gen.leaf_attachments(&make_stem_perlin(&config));
assert_eq!(atts.len(), config.leaf_pairs * 2 + 1);
}
#[test]
fn sympodial_attachment_count() {
let config = TwigConfig {
sympodial: true,
..TwigConfig::default()
};
let twig_gen = TwigGenerator::new(config.clone());
let atts = twig_gen.leaf_attachments(&make_stem_perlin(&config));
assert_eq!(atts.len(), config.leaf_pairs + 1);
}
#[test]
fn monopodial_leaves_are_opposite() {
let config = TwigConfig {
sympodial: false,
stem_curve: 0.0,
..TwigConfig::default()
};
let twig_gen = TwigGenerator::new(config.clone());
let atts = twig_gen.leaf_attachments(&make_stem_perlin(&config));
let n = config.leaf_pairs;
for i in 0..n {
let right = &atts[1 + i * 2];
let left = &atts[1 + i * 2 + 1];
assert!(
(right.angle + left.angle).abs() < 1e-9,
"monopodial pair {i}: angles should sum to zero (got {} + {})",
right.angle,
left.angle,
);
}
}
#[test]
fn sympodial_leaves_alternate_sides() {
let config = TwigConfig {
sympodial: true,
stem_curve: 0.0,
leaf_pairs: 4,
..TwigConfig::default()
};
let twig_gen = TwigGenerator::new(config.clone());
let atts = twig_gen.leaf_attachments(&make_stem_perlin(&config));
for (i, att) in atts.iter().skip(1).take(config.leaf_pairs).enumerate() {
let expected_sign = if i % 2 == 0 { 1.0_f64 } else { -1.0 };
assert!(
att.angle * expected_sign > 0.0,
"sympodial leaf {i}: angle should be on side {expected_sign:+} (got {})",
att.angle,
);
}
}
#[test]
fn stem_tapers_to_zero_at_tip() {
assert!(stem_half_width_at(0.0, 0.015) < 1e-9);
assert!(stem_half_width_at(1.0, 0.015) > 0.014);
}
#[test]
fn generator_produces_correct_buffer_sizes() {
let twig_gen = TwigGenerator::new(TwigConfig::default());
let map = twig_gen.generate(64, 64).expect("generate failed");
assert_eq!(map.albedo.len(), 64 * 64 * 4);
assert_eq!(map.normal.len(), 64 * 64 * 4);
assert_eq!(map.roughness.len(), 64 * 64 * 4);
}
#[test]
fn generator_has_transparent_and_opaque_pixels() {
let twig_gen = TwigGenerator::new(TwigConfig::default());
let map = twig_gen.generate(128, 128).expect("generate failed");
assert!(
map.albedo.chunks(4).any(|px| px[3] == 0),
"twig texture should have transparent pixels"
);
assert!(
map.albedo.chunks(4).any(|px| px[3] == 255),
"twig texture should have opaque pixels"
);
}
#[test]
fn stem_center_is_opaque_when_straight() {
let config = TwigConfig {
stem_curve: 0.0,
sympodial: false,
..TwigConfig::default()
};
let twig_gen = TwigGenerator::new(config);
let map = twig_gen.generate(128, 128).expect("generate failed");
let idx = (64 * 128 + 64) * 4;
assert_eq!(
map.albedo[idx + 3],
255,
"straight stem center should be opaque"
);
}
#[test]
fn pixel_to_leaf_uv_symmetric() {
let att_right = LeafAttachment {
attach_u: 0.5,
attach_v: 0.5,
angle: 1.0,
scale: 0.4,
};
let att_left = LeafAttachment {
attach_u: 0.5,
attach_v: 0.5,
angle: -1.0,
scale: 0.4,
};
let (ru, rv) = pixel_to_leaf_uv(0.8, 0.5, &att_right);
let (lu, lv) = pixel_to_leaf_uv(0.2, 0.5, &att_left);
assert!(
(rv - lv).abs() < 1e-12,
"v_local should be equal: {rv} vs {lv}"
);
assert!(
((ru - 0.5).abs() - (lu - 0.5).abs()).abs() < 1e-12,
"u_local distance from midrib should match: |{ru}-0.5|={} vs |{lu}-0.5|={}",
(ru - 0.5).abs(),
(lu - 0.5).abs(),
);
}
#[test]
fn stem_base_is_centered() {
for sympodial in [false, true] {
for seed in [0u32, 1, 42] {
let config = TwigConfig {
sympodial,
leaf: crate::leaf::LeafConfig {
seed,
..Default::default()
},
..TwigConfig::default()
};
let perlin = make_stem_perlin(&config);
let u = stem_center_u(1.0, &config, &perlin);
assert!(
(u - 0.5).abs() < 1e-12,
"stem base should be at u=0.5 (sympodial={sympodial}, seed={seed}), got {u}"
);
}
}
}
#[test]
fn sympodial_generator_has_transparent_and_opaque() {
let config = TwigConfig {
sympodial: true,
..TwigConfig::default()
};
let twig_gen = TwigGenerator::new(config);
let map = twig_gen.generate(128, 128).expect("generate failed");
assert!(map.albedo.chunks(4).any(|px| px[3] == 0));
assert!(map.albedo.chunks(4).any(|px| px[3] == 255));
}
}