bevy_symbios_texture 0.6.0

Algorithmic texture generator for Bevy.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
//! Twig texture generator — a composite foliage card.
//!
//! A twig composites multiple leaves and an organic stem into a single
//! alpha-masked texture.  The stem tapers from a thick base (`v = 1`) to a
//! pointed terminal tip (`v = 0`) and is gently curved via Perlin noise.
//!
//! # Phyllotaxis modes
//! Controlled by [`TwigConfig::sympodial`]:
//!
//! * **Monopodial (`false`)** — a single continuous axis carries opposite leaf
//!   pairs at each node.  The axis stays relatively straight with a slight
//!   organic curve.  A terminal leaf caps the apex.
//!
//! * **Sympodial (`true`)** — each node produces one dominant lateral and one
//!   suppressed bud.  The axis appears to zigzag because each internode is
//!   really the continuation of a lateral shoot.  Leaves are alternate
//!   (one per node) and positioned at the bend points of the zigzag.
//!
//! # Coordinate conventions
//! * Texture UV: `u = 0` left, `u = 1` right, `v = 0` **tip** (apex),
//!   `v = 1` **base** (attachment to parent branch).
//! * Upload the result with
//!   [`map_to_images_card`](crate::generator::map_to_images_card).

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},
};

// --- tuning constants -------------------------------------------------------

/// Perlin spatial frequency for the organic stem wiggle.
const STEM_CURVE_FREQ: f64 = 1.8;

/// Seed offset applied to the leaf seed to generate independent stem curvature.
const STEM_PERLIN_SEED_OFFSET: u32 = 77;

/// Relative Y-offset in Perlin space so the stem curve is decorrelated from
/// any future second dimension sampling on the same noise object.
const STEM_PERLIN_Y: f64 = 13.7;

/// Amplitude of the sympodial zigzag relative to `stem_curve`.
const SYMPODIAL_ZZ_SCALE: f64 = 1.5;

/// Power used to taper stem width: 1.0 = linear, < 1.0 = wider for longer.
const STEM_TAPER_POW: f64 = 0.55;

/// Stem half-width at the mid-internode as a fraction of the local tapered width.
/// Controls how narrow the stem is between knots; lower = more knobbly.
const INTERNODE_WIDTH: f64 = 0.45;

/// Scale of the terminal leaf relative to lateral leaves.
const TERMINAL_SCALE: f64 = 0.72;

// ----------------------------------------------------------------------------

/// Configures the appearance of a [`TwigGenerator`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct TwigConfig {
    /// Leaf appearance shared by every leaf on the twig.
    pub leaf: LeafConfig,
    /// Stem colour in linear RGB \[0, 1\].
    pub stem_color: [f32; 3],
    /// Half-width of the stem at the base (`v = 1`) in UV space.
    /// The stem tapers to zero at the tip (`v = 0`).
    pub stem_half_width: f64,
    /// Number of lateral leaf nodes.
    /// Monopodial: `leaf_pairs` opposite pairs.  Sympodial: `leaf_pairs`
    /// alternate leaves.
    pub leaf_pairs: usize,
    /// Angle of each lateral leaf measured from the stem axis, in radians.
    ///
    /// * `0` — leaf points along the stem toward the base.
    /// * `π/2` — leaf is perpendicular to the stem (horizontal on a vertical twig).
    /// * `> π/2` — leaf tip angles toward the stem tip (acroscopic / upswept).
    /// * `π` — leaf points straight toward the apex.
    pub leaf_angle: f64,
    /// Scale of each lateral leaf card in UV space.
    pub leaf_scale: f64,
    /// Amplitude of the organic stem curvature in UV space.
    /// `0.0` = perfectly straight; `0.05` is a natural-looking default.
    pub stem_curve: f64,
    /// `false` → monopodial (opposite pairs, continuous axis);
    /// `true` → sympodial (alternate leaves, zigzag axis).
    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, // ≈ 70° — slightly below perpendicular, drooping
            leaf_scale: 0.38,
            stem_curve: 0.015,
            sympodial: true,
        }
    }
}

/// A single leaf card placed on the twig.
pub struct LeafAttachment {
    /// Stem attachment point (U coordinate in texture space).
    pub attach_u: f64,
    /// Stem attachment point (V coordinate in texture space).
    pub attach_v: f64,
    /// Total rotation angle of the leaf axis in texture space, measured from
    /// the downward direction (`+V`).  Positive = clockwise = right side.
    /// Equals `stem_tangent_at(attach_v) + relative_leaf_angle`.
    pub angle: f64,
    /// Uniform scale factor (leaf UV space → texture UV space).
    pub scale: f64,
}

/// Procedural twig texture generator.
///
/// Composites a tapered, curved stem and multiple leaves into an alpha-masked
/// foliage card.  Upload the result with
/// [`map_to_images_card`](crate::generator::map_to_images_card).
pub struct TwigGenerator {
    config: TwigConfig,
}

impl TwigGenerator {
    /// Create a new generator with the given configuration.
    pub fn new(config: TwigConfig) -> Self {
        Self { config }
    }

    /// Build the list of leaf attachment descriptors.
    ///
    /// `stem_perlin` must be the same Perlin instance used for the stem
    /// centerline in [`generate`](TextureGenerator::generate).
    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)
        }
    }

    // --- monopodial ----------------------------------------------------------

    /// Opposite leaf pairs + terminal leaf.
    fn monopodial_attachments(&self, n: usize, perlin: &Perlin) -> Vec<LeafAttachment> {
        let c = &self.config;
        // 1 terminal + 2 leaves per node.
        let mut atts = Vec::with_capacity(n * 2 + 1);

        // The terminal leaf card extends toward the tip (v=0) by `leaf_scale *
        // TERMINAL_SCALE` texture units.  Push its attachment point far enough
        // from the edge that the whole card stays inside the texture.
        let term_v = terminal_v(c);

        // Terminal leaf is inserted first so it wins the per-pixel Z-order
        // loop in `generate` (first hit breaks).  This prevents lateral leaves
        // with acroscopic angles from occluding the apex.
        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, // pointing toward tip (upward)
            scale: c.leaf_scale * TERMINAL_SCALE,
        });

        // Lateral leaves run from just below the terminal to near the base.
        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, // right leaf
                scale: c.leaf_scale,
            });
            atts.push(LeafAttachment {
                attach_u,
                attach_v,
                angle: tangent - c.leaf_angle, // left leaf (mirror)
                scale: c.leaf_scale,
            });
        }

        atts
    }

    // --- sympodial -----------------------------------------------------------

    /// Alternate leaves at zigzag extrema + terminal leaf.
    ///
    /// In sympodial phyllotaxis the axis appears to zigzag because each
    /// internode is a lateral that became dominant.  Leaves are positioned at
    /// the bend points of the zigzag (sine extrema) and branch off on the
    /// **convex** (outer) side of each bend.
    fn sympodial_attachments(&self, n: usize, perlin: &Perlin) -> Vec<LeafAttachment> {
        let c = &self.config;
        // 1 terminal + 1 leaf per node.
        let mut atts = Vec::with_capacity(n + 1);

        let term_v = terminal_v(c);

        // Terminal leaf first — wins Z-order over lateral leaves.
        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 {
            // Position leaves at the extrema of the sine zigzag.
            // sin(pv * n * PI) has extrema at pv = (2k+1) / (2n).
            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);

            // The zigzag is sin(pv * n * PI) * … .  At the k-th extremum the
            // sign is (-1)^k (sin at (2k+1)*PI/2 = cos(k*PI) = (-1)^k).
            // Leaf is on the convex (outer) side: same sign as the zigzag.
            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;

        // A separate Perlin instance for the stem so its curve is uncorrelated
        // with the leaf edge-serration noise.
        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;

        // The terminal leaf caps the stem; suppress stem rendering above it.
        let term_v = terminal_v(c);

        // Node V-positions used to shape the knobbly segments.
        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;

            // Stem centerline, tapered half-width, and knobbly segment factor.
            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();

                // --- Leaf composite (evaluated first: leaves take priority over stem) ---
                // In a real twig the leaf blade overlaps and obscures the stem
                // at the attachment point; evaluating leaves first models that correctly.
                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;
                }

                // --- Stem SDF (lower priority than leaves) ---
                // Do not draw the stem above the terminal leaf attachment: the
                // terminal leaf covers that region intentionally, and the bare
                // stem tip would visibly pierce through the apex leaf.
                // eff_hw modulates the cross-section width per segment so the
                // stem appears as a chain of knobbly segments joined at nodes.
                if s_hw > 1e-9 && dist_to_stem < eff_hw && pv >= term_v {
                    // Bright ridge at the stem centre; height encodes both the
                    // cross-sectional round profile and the segment bulge shape.
                    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 {
                    // Write edge color into RGB to prevent dark halos from
                    // bilinear filtering at silhouette boundaries.
                    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,
        })
    }
}

// --- stem helpers -----------------------------------------------------------

/// V attachment position for the terminal leaf.
///
/// The terminal leaf card extends `leaf_scale * TERMINAL_SCALE` units toward
/// the tip (`v = 0`).  Placing the attachment at this V ensures the whole card
/// remains inside the `[0, 1]` texture with a small margin.
#[inline]
fn terminal_v(config: &TwigConfig) -> f64 {
    config.leaf_scale * TERMINAL_SCALE + 0.03
}

/// U coordinate of the stem centreline at a given V (tip-to-base axis).
///
/// Combines a slow organic Perlin wiggle with an optional sympodial sine
/// zigzag whose amplitude grows from zero at the tip to maximum at the base.
/// Both components are anchored so that the stem base (`pv = 1`) is always
/// exactly centred at `u = 0.5`.
fn stem_center_u(pv: f64, config: &TwigConfig, perlin: &Perlin) -> f64 {
    // Organic curvature: slow single-frequency Perlin wiggle.
    // Subtract the value at pv=1 so the base attachment is always at u=0.5.
    let organic = (perlin.get([pv * STEM_CURVE_FREQ, STEM_PERLIN_Y])
        - perlin.get([STEM_CURVE_FREQ, STEM_PERLIN_Y]))
        * config.stem_curve;

    // Sympodial zigzag: sine wave, amplitude = 0 at tip, grows toward base.
    // Phase is computed over the lateral-leaf span [lat_start, 0.88] so that
    // the sine extrema align with the attach_v positions from
    // sympodial_attachments (which place leaves at normalized = (2k+1)/(2n)
    // within that same span).
    // Subtracting the base value anchors the zigzag to zero at pv=1.
    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
    };

    // Clamp so the stem stays safely inside the texture.
    (0.5 + organic + zigzag).clamp(0.08, 0.92)
}

/// Half-width of the stem at V position `pv` after tapering.
///
/// `pv = 0` (tip) → zero width; `pv = 1` (base) → `half_width`.
fn stem_half_width_at(pv: f64, half_width: f64) -> f64 {
    half_width * pv.powf(STEM_TAPER_POW)
}

/// V positions of each inter-segment node along the stem.
///
/// Nodes coincide with the leaf-attachment V positions so that the knobbly
/// constrictions fall exactly at the peaks and valleys of the sympodial zigzag
/// (or at the regular internode positions in monopodial mode).
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 {
            // Sine extrema — matches sympodial_attachments exactly.
            (2.0 * k + 1.0) / (2.0 * n as f64)
        } else {
            // Evenly spaced — matches monopodial_attachments exactly.
            k / n as f64
        };
        vs.push(lat_start + normalized * lat_span);
    }
    vs
}

/// Width-modulation factor for the stem at V position `pv`.
///
/// Returns a value in `[INTERNODE_WIDTH, 1.0]`:
/// * Peaks at `1.0` at each node joint — the swollen knot where a leaf attaches.
/// * Dips to `INTERNODE_WIDTH` at the mid-point between nodes (the internode).
/// * Below the last node the stem stays at full width toward the base.
fn stem_segment_factor(pv: f64, node_vs: &[f64]) -> f64 {
    let n = node_vs.len();
    // Find the segment [lo, hi] that brackets pv.
    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);
            // cos²(t·π): 1 at both node ends, 0 at the internode midpoint.
            let c = (t * PI).cos();
            return INTERNODE_WIDTH + (1.0 - INTERNODE_WIDTH) * c * c;
        }
    }
    // Base region (below the last node): full width toward the attachment base.
    1.0
}

/// Angle of the stem tangent at `pv` from the downward direction (`+V`),
/// computed via a numerical derivative.  Central differences are used in the
/// interior; one-sided differences are used at the boundaries so the divisor
/// always matches the actual sample spacing.
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 {
        // Forward difference at the lower boundary.
        (
            stem_center_u(pv, config, perlin),
            stem_center_u(pv + delta, config, perlin),
            delta,
        )
    } else if pv > 1.0 - delta {
        // Backward difference at the upper boundary.
        (
            stem_center_u(pv - delta, config, perlin),
            stem_center_u(pv, config, perlin),
            delta,
        )
    } else {
        // Central difference in the interior.
        (
            stem_center_u(pv - delta, config, perlin),
            stem_center_u(pv + delta, config, perlin),
            2.0 * delta,
        )
    };
    // du/dv: horizontal displacement per unit of V.
    let du_dv = (u_hi - u_lo) / dv;
    // Angle from the downward (+V) direction.
    du_dv.atan2(1.0)
}

// --- leaf transform ---------------------------------------------------------

/// Map a texture-space pixel `(pu, pv)` into the local UV space of a leaf.
///
/// Uses the leaf attachment point and the baked total angle (stem tangent +
/// relative leaf angle) to invert the 2D rotation.
///
/// Leaf local UV: `u = 0.5` → midrib; `v = 0` → attachment; `v = 1` → tip.
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;

    // World-to-local rotation: local +V = (sin θ, cos θ) in texture space.
    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)
}

// --- tests ------------------------------------------------------------------

#[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));
        // 2 per pair + 1 terminal.
        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));
        // 1 per node + 1 terminal.
        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));
        // atts[0] is the terminal; lateral pairs start at index 1.
        // Each pair should have opposite angles (sum to zero for straight stem).
        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));
        // atts[0] is the terminal; lateral leaves are at indices 1..=leaf_pairs.
        // Alternate: right (positive angle), left (negative), right, left, …
        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() {
        // With stem_curve=0 the centerline stays at u=0.5, making the center
        // pixel at (64, 64) in a 128×128 texture reliably on the stem.
        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() {
        // stem_center_u(1.0) must equal 0.5 regardless of seed / sympodial mode.
        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));
    }
}