bevy_symbios 0.4.0

Bevy integration for the Symbios L-System ecosystem.
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
//! Mesh generation for L-System skeletons.
//!
//! This module converts [`Skeleton`] data from `symbios-turtle-3d` into Bevy [`Mesh`]es.
//! Each skeleton strand becomes a smooth tube mesh using parallel transport for
//! twist-free geometry.

use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use std::hash::{DefaultHasher, Hash, Hasher};
use symbios_turtle_3d::{Skeleton, SkeletonPoint};

// Helper struct to build a single mesh
#[derive(Default)]
struct MeshData {
    positions: Vec<Vec3>,
    normals: Vec<Vec3>,
    colors: Vec<[f32; 4]>,
    uvs: Vec<[f32; 2]>,
    indices: Vec<u32>,
}

impl MeshData {
    fn to_mesh(&self) -> Mesh {
        let mut mesh = Mesh::new(
            PrimitiveTopology::TriangleList,
            RenderAssetUsages::default(),
        );
        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, self.positions.clone());
        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, self.normals.clone());
        mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, self.colors.clone());
        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, self.uvs.clone());
        mesh.insert_indices(Indices::U32(self.indices.clone()));
        let _ = mesh.generate_tangents();
        mesh
    }
}

/// Maximum allowed tube resolution to prevent memory exhaustion.
/// 128 vertices per ring is more than sufficient for smooth tubes.
const MAX_RESOLUTION: u32 = 128;

/// Converts L-System skeletons into Bevy meshes.
///
/// Generates smooth tube geometry from [`Skeleton`] strands using parallel transport
/// to avoid twisting artifacts. Segments are bucketed by material ID, producing
/// separate meshes for each material.
///
/// # Features
///
/// - **Multi-material support**: Segments with different `material_id` values produce
///   separate meshes, allowing different Bevy materials to be applied.
/// - **Vertex colors**: Per-vertex colors from [`SkeletonPoint::color`] are included.
/// - **UV mapping**: Arc-length parameterized UVs with aspect-ratio preservation.
///   U wraps around the tube (0.0 to 1.0), V increases along the strand.
///   V is scaled by each point's [`SkeletonPoint::uv_scale`] factor.
/// - **Smooth geometry**: Parallel transport prevents tube twisting at bends.
///
/// # Example
///
/// ```ignore
/// use bevy_symbios::LSystemMeshBuilder;
///
/// let skeleton = /* ... generate skeleton ... */;
/// let meshes = LSystemMeshBuilder::new()
///     .with_resolution(12)
///     .build(&skeleton);
///
/// for (material_id, mesh) in meshes {
///     // Spawn each mesh with appropriate material
/// }
/// ```
pub struct LSystemMeshBuilder {
    buckets: HashMap<u16, MeshData>,
    resolution: u32,
}

impl Default for LSystemMeshBuilder {
    fn default() -> Self {
        Self {
            buckets: HashMap::new(),
            resolution: 8,
        }
    }
}

impl LSystemMeshBuilder {
    /// Creates a new mesh builder with default settings (resolution = 8).
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the number of vertices around each ring of the tube.
    ///
    /// Higher values produce smoother tubes but increase vertex count.
    /// Minimum value is 3 (triangular cross-section), maximum is 128.
    /// Values outside this range are clamped with a warning. Default is 8.
    pub fn with_resolution(mut self, res: u32) -> Self {
        if res > MAX_RESOLUTION {
            warn!(
                "Mesh resolution {} exceeds maximum of {}; clamping to {}",
                res, MAX_RESOLUTION, MAX_RESOLUTION
            );
        }
        self.resolution = res.clamp(3, MAX_RESOLUTION);
        self
    }

    /// Builds meshes from the skeleton, consuming the builder.
    ///
    /// Returns a map from material ID to [`Mesh`]. Each mesh contains all segments
    /// that share the same `material_id` from their starting [`SkeletonPoint`].
    ///
    /// Empty skeletons or strands with fewer than 2 points produce no output.
    pub fn build(mut self, skeleton: &Skeleton) -> HashMap<u16, Mesh> {
        for strand in &skeleton.strands {
            if strand.len() < 2 {
                continue;
            }
            self.process_strand(strand);
        }

        self.buckets
            .into_iter()
            .map(|(k, v)| (k, v.to_mesh()))
            .collect()
    }

    fn process_strand(&mut self, points: &[SkeletonPoint]) {
        // Filter out duplicate adjacent points (zero-length segments) to prevent NaNs.
        // Build a list by keeping only points whose position differs from the last kept point.
        let filtered: Vec<&SkeletonPoint> = {
            let mut result = vec![&points[0]];
            for point in &points[1..] {
                let last = result.last().unwrap();
                if last.position.distance_squared(point.position) > 0.000001 {
                    result.push(point);
                }
            }
            result
        };

        if filtered.len() < 2 {
            return;
        }

        let points = filtered;
        let n = points.len();

        // Phase 1: Compute per-point rotations via parallel transport.
        // Each point gets its own rotation based on its miter tangent, enabling
        // vertex sharing between consecutive same-material segments.
        let rotations = {
            let mut rots = Vec::with_capacity(n);

            // Point 0: align turtle rotation with first segment tangent
            let tangent_0 = (points[1].position - points[0].position).normalize_or_zero();
            let mut rot = points[0].rotation;
            let turtle_fwd = rot * Vec3::Y;
            rot = Self::robust_rotation_arc(turtle_fwd, tangent_0) * rot;
            rots.push(rot);

            // Points 1..N-1: use miter tangent (or endpoint tangent for last point)
            for i in 1..n {
                let tangent = if i < n - 1 {
                    let v_in = (points[i].position - points[i - 1].position).normalize_or_zero();
                    let v_out = (points[i + 1].position - points[i].position).normalize_or_zero();
                    let sum = v_in + v_out;
                    if sum.length_squared() < 0.001 {
                        v_in
                    } else {
                        sum.normalize()
                    }
                } else {
                    (points[i].position - points[i - 1].position).normalize_or_zero()
                };

                let fwd = rot * Vec3::Y;
                let bend = Self::robust_rotation_arc(fwd, tangent);
                rot = bend * rot;
                rots.push(rot);
            }

            rots
        };

        // Phase 2: Compute per-point V coordinates using incremental accumulation.
        // This ensures UV continuity across tapered segments where circumference varies.
        let v_coords = {
            let mut coords = Vec::with_capacity(n);
            let mut cumulative_v = 0.0f32;
            coords.push(0.0);

            for i in 0..n - 1 {
                let seg_len = points[i].position.distance(points[i + 1].position);
                let avg_radius = (points[i].radius + points[i + 1].radius) * 0.5;
                let circumference = avg_radius * std::f32::consts::TAU;
                let v_scale = if circumference > 0.0001 {
                    1.0 / circumference
                } else {
                    1.0
                };
                cumulative_v += seg_len * v_scale * points[i].uv_scale;
                coords.push(cumulative_v);
            }

            coords
        };

        // Phase 3: Generate rings and connect, with vertex sharing.
        // When consecutive segments share the same material ID, the top ring of
        // segment N is reused as the bottom ring of segment N+1.
        let mut ring_cache: Vec<Option<(u16, u32)>> = vec![None; n];

        for i in 0..n - 1 {
            let curr = points[i];
            let next = points[i + 1];
            let mat_id = curr.material_id as u16;
            let bucket = self.buckets.entry(mat_id).or_default();

            // Bottom ring: reuse cached ring if same material bucket already has one
            let bottom_idx = match ring_cache[i] {
                Some((cached_mat, idx)) if cached_mat == mat_id => idx,
                _ => Self::add_ring(
                    bucket,
                    curr.position,
                    rotations[i],
                    curr.radius,
                    curr.color,
                    v_coords[i],
                    self.resolution,
                ),
            };

            // Top ring: always generate fresh
            let top_idx = Self::add_ring(
                bucket,
                next.position,
                rotations[i + 1],
                next.radius,
                next.color,
                v_coords[i + 1],
                self.resolution,
            );

            Self::connect_rings(bucket, bottom_idx, top_idx, self.resolution);

            // Cache the top ring for potential reuse by the next segment
            ring_cache[i + 1] = Some((mat_id, top_idx));
        }
    }

    fn robust_rotation_arc(from: Vec3, to: Vec3) -> Quat {
        const DOT_THRESHOLD: f32 = 0.9999;
        let dot = from.dot(to);
        if dot < -DOT_THRESHOLD {
            let axis = if from.x.abs() < 0.8 {
                Vec3::X.cross(from).normalize()
            } else {
                Vec3::Y.cross(from).normalize()
            };
            return Quat::from_axis_angle(axis, std::f32::consts::PI);
        } else if dot > DOT_THRESHOLD {
            return Quat::IDENTITY;
        }
        Quat::from_rotation_arc(from, to)
    }

    fn add_ring(
        data: &mut MeshData,
        center: Vec3,
        rotation: Quat,
        radius: f32,
        color: Vec4,
        v_coord: f32,
        res: u32,
    ) -> u32 {
        let start_index = data.positions.len() as u32;
        let color_array = color.to_array();

        for i in 0..=res {
            let u = i as f32 / res as f32;
            let theta = u * std::f32::consts::TAU;
            let (sin, cos) = theta.sin_cos();

            let local_pos = Vec3::new(cos * radius, 0.0, sin * radius);
            let local_normal = Vec3::new(cos, 0.0, sin);

            data.positions.push(center + (rotation * local_pos));
            data.normals.push(rotation * local_normal);
            data.colors.push(color_array);
            data.uvs.push([u, v_coord]);
        }
        start_index
    }

    fn connect_rings(data: &mut MeshData, bottom_start: u32, top_start: u32, res: u32) {
        for i in 0..res {
            let bottom_curr = bottom_start + i;
            let bottom_next = bottom_start + i + 1;
            let top_curr = top_start + i;
            let top_next = top_start + i + 1;

            data.indices.push(bottom_curr);
            data.indices.push(top_curr);
            data.indices.push(bottom_next);

            data.indices.push(bottom_next);
            data.indices.push(top_curr);
            data.indices.push(top_next);
        }
    }
}

// ---------------------------------------------------------------------------
// Mesh cache
// ---------------------------------------------------------------------------

/// Resource that maps a skeleton fingerprint to its built per-material [`Mesh`]
/// handles, enabling re-spawn of identical L-systems without re-meshing.
///
/// # Invalidation
///
/// Auto-invalidation: each call to [`LSystemMeshBuilder::build_cached`]
/// recomputes a fingerprint over the skeleton's strands (positions, rotations,
/// radii, colors, material IDs, UV scales) plus the builder's resolution. If
/// any of those change, the fingerprint changes and a fresh mesh is built. The
/// previous entry remains in the cache until [`MeshCache::clear`] is called —
/// the cache does not LRU-evict on its own.
///
/// # Parity with `bevy_symbios_shape::ShapeMeshCache`
///
/// This cache deliberately mirrors the API surface of
/// `bevy_symbios_shape::ShapeMeshCache` so that downstream apps can wire
/// procedural-mesh observability uniformly across both pipelines. Both expose
/// `get_or_insert_with`, `len`, `is_empty`, `clear`, and `hits` / `misses` /
/// `reset_stats` cumulative counters. The two caches stay separate types
/// (different keys, different value shapes — opaque skeleton fingerprint →
/// `HashMap<material_id, Handle<Mesh>>` here vs. structured terminal key →
/// single `Handle<Mesh>` there), but the operational vocabulary is shared.
///
/// # Trade-off
///
/// Caching trades memory for CPU. Each cached entry keeps its [`Mesh`] assets
/// alive (the [`Handle`]s live in the cache). For long-running scenes that
/// generate many unique skeletons, call [`MeshCache::clear`] periodically to
/// release them, or manage a coarser eviction policy at the application layer.
#[derive(Resource, Default, Debug)]
pub struct MeshCache {
    entries: HashMap<u64, HashMap<u16, Handle<Mesh>>>,
    hits: u64,
    misses: u64,
}

impl MeshCache {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Drop all cached entries, releasing the underlying [`Handle<Mesh>`]
    /// references. The mesh assets are freed once no other strong handles
    /// remain. Hit/miss counters are preserved (use [`Self::reset_stats`]).
    pub fn clear(&mut self) {
        self.entries.clear();
    }

    /// Returns `true` if a fingerprint matching the given skeleton+resolution
    /// is already cached. Useful for tests and instrumentation. This does
    /// not bump the hit/miss counters.
    pub fn contains(&self, skeleton: &Skeleton, resolution: u32) -> bool {
        self.entries
            .contains_key(&compute_fingerprint(skeleton, resolution))
    }

    /// Cumulative cache hits since construction (or last [`Self::reset_stats`]).
    /// Bumped by [`LSystemMeshBuilder::build_cached`] and by
    /// [`Self::get_or_insert_with`].
    pub fn hits(&self) -> u64 {
        self.hits
    }

    /// Cumulative cache misses since construction (or last [`Self::reset_stats`]).
    pub fn misses(&self) -> u64 {
        self.misses
    }

    /// Reset hit/miss counters without clearing entries. The cached
    /// [`Handle<Mesh>`]s remain live.
    pub fn reset_stats(&mut self) {
        self.hits = 0;
        self.misses = 0;
    }

    /// Lookup-or-build by an explicit fingerprint. Returns a clone of the
    /// cached entry on hit (incrementing `hits`), or runs `build`, inserts
    /// the resulting handles, and returns them (incrementing `misses`).
    ///
    /// Callers driving caching outside of [`LSystemMeshBuilder::build_cached`]
    /// — e.g. mixing skeleton-derived meshes with externally-supplied ones
    /// under a custom fingerprint — should reach for this. Use
    /// [`compute_skeleton_fingerprint`] to derive the same fingerprint
    /// `build_cached` uses.
    pub fn get_or_insert_with<F>(
        &mut self,
        fingerprint: u64,
        build: F,
    ) -> HashMap<u16, Handle<Mesh>>
    where
        F: FnOnce() -> HashMap<u16, Handle<Mesh>>,
    {
        if let Some(handles) = self.entries.get(&fingerprint) {
            self.hits += 1;
            return handles.clone();
        }
        self.misses += 1;
        let handles = build();
        self.entries.insert(fingerprint, handles.clone());
        handles
    }
}

/// Hash a skeleton + resolution down to the same `u64` fingerprint
/// [`LSystemMeshBuilder::build_cached`] uses internally. Exposed so callers
/// of [`MeshCache::get_or_insert_with`] can produce keys that collide with
/// the builder's own.
pub fn compute_skeleton_fingerprint(skeleton: &Skeleton, resolution: u32) -> u64 {
    compute_fingerprint(skeleton, resolution)
}

impl LSystemMeshBuilder {
    /// Like [`LSystemMeshBuilder::build`], but consults `cache` first. On a
    /// cache hit, returns the cached [`Handle<Mesh>`]s without re-meshing.
    /// On a miss, builds the meshes, inserts them into `meshes`, stores the
    /// resulting handles in the cache under the skeleton's fingerprint, and
    /// returns them. Bumps the cache's `hits`/`misses` counters accordingly.
    ///
    /// Callers that re-spawn the same L-system many times (props, tile-based
    /// foliage, deterministic regrowth) should prefer this over `build`.
    pub fn build_cached(
        self,
        skeleton: &Skeleton,
        cache: &mut MeshCache,
        meshes: &mut Assets<Mesh>,
    ) -> HashMap<u16, Handle<Mesh>> {
        let fingerprint = compute_fingerprint(skeleton, self.resolution);
        if let Some(handles) = cache.entries.get(&fingerprint) {
            cache.hits += 1;
            return handles.clone();
        }
        cache.misses += 1;
        let mesh_buckets = self.build(skeleton);
        let handles: HashMap<u16, Handle<Mesh>> = mesh_buckets
            .into_iter()
            .map(|(id, mesh)| (id, meshes.add(mesh)))
            .collect();
        cache.entries.insert(fingerprint, handles.clone());
        handles
    }
}

fn compute_fingerprint(skeleton: &Skeleton, resolution: u32) -> u64 {
    let mut hasher = DefaultHasher::new();
    resolution.hash(&mut hasher);
    skeleton.strands.len().hash(&mut hasher);
    for strand in &skeleton.strands {
        strand.len().hash(&mut hasher);
        for point in strand {
            hash_skeleton_point(point, &mut hasher);
        }
    }
    skeleton.strand_parents.hash(&mut hasher);
    hasher.finish()
}

fn hash_skeleton_point<H: Hasher>(p: &SkeletonPoint, hasher: &mut H) {
    p.position.x.to_bits().hash(hasher);
    p.position.y.to_bits().hash(hasher);
    p.position.z.to_bits().hash(hasher);
    p.rotation.x.to_bits().hash(hasher);
    p.rotation.y.to_bits().hash(hasher);
    p.rotation.z.to_bits().hash(hasher);
    p.rotation.w.to_bits().hash(hasher);
    p.radius.to_bits().hash(hasher);
    p.color.x.to_bits().hash(hasher);
    p.color.y.to_bits().hash(hasher);
    p.color.z.to_bits().hash(hasher);
    p.color.w.to_bits().hash(hasher);
    p.material_id.hash(hasher);
    p.uv_scale.to_bits().hash(hasher);
}