rustial-renderer-bevy 0.0.1

Bevy Engine renderer for the rustial 2.5D map engine
//! # Vector geometry sync system
//!
//! Synchronises tessellated vector layer meshes from the engine's
//! [`MapState::vector_meshes`](rustial_engine::MapState) to Bevy mesh
//! entities each frame.
//!
//! ## Data flow
//!
//! ```text
//! VectorLayer::tessellate()  (engine, during MapState::update)
//!     |
//!     |  produces Vec<VectorMeshData>
//!     |  positions in Web Mercator metres (f64)
//!     |  per-vertex RGBA colours
//!     v
//! sync_vectors (Update)            <-- this system
//!     |
//!     |  Phase 1: despawn stale entities
//!     |  Phase 2: reposition surviving entities
//!     |  Phase 3: spawn new entities from VectorMeshData
//!     v
//! Bevy render graph picks up mesh + material
//! ```
//!
//! ## Entity lifecycle
//!
//! Vector entities use a **fingerprint-based rebuild** strategy:
//!
//! 1. **Data gone** -- if the engine's `vector_meshes` is empty, all
//!    existing [`VectorEntity`] entities are despawned.
//! 2. **Fingerprint changed** -- if the vector data fingerprint (count
//!    plus per-layer vertex/index sizes) differs from the cached value,
//!    all old entities are despawned and new ones are spawned.
//! 3. **Fingerprint stable** -- only [`Transform`] translations are
//!    updated (camera-relative repositioning) without re-creating
//!    meshes or materials.
//!
//! ## Coordinate pipeline
//!
//! ```text
//! VectorMeshData.positions  (f64, Web Mercator metres)
//!     |
//!     |  subtract camera_origin  (at spawn time, baked into vertex data)
//!     v
//! mesh-local (f32)  -- small values, centred near camera at spawn
//!     |
//!     |  Transform.translation  (updated every frame)
//!     v
//! camera-relative (f32)  -- accounts for camera drift since spawn
//! ```
//!
//! At **spawn** the camera origin at that instant is subtracted from
//! every vertex position and stored in the mesh.  The entity remembers
//! this origin in [`VectorEntity::spawn_origin`].  Each subsequent
//! frame the `Transform` translation is set to
//! `spawn_origin - current_origin` so that the net position equals
//! `world_pos - current_origin`.
//!
//! ## Vertex colours
//!
//! [`VectorMeshData`] carries per-vertex colours in linear RGBA.  When
//! at least one colour is present, the material's `base_color` is set
//! from the **first** vertex colour (uniform across the mesh).  The
//! vector pipeline does not yet use `Mesh::ATTRIBUTE_COLOR` because
//! Bevy's `StandardMaterial` does not blend per-vertex colour with the
//! base texture by default.  A custom shader or `MaterialExtension`
//! could be added in the future for full per-vertex colour support.
//!
//! ## Material
//!
//! Each entity uses an unlit [`StandardMaterial`] with
//! [`AlphaMode::Blend`] so that semi-transparent vector fills render
//! correctly.  The material is created at spawn time and is not
//! updated on subsequent frames.
//!
//! ## Scheduling
//!
//! Registered in [`Update`](bevy::prelude::Update) -- after
//! `PreUpdate` has ticked the engine state and synced the camera.
// ---------------------------------------------------------------------------

use crate::components::VectorEntity;
use crate::plugin::MapStateResource;
use crate::systems::frame_change_detection::{frame_unchanged, FrameChangeDetection};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use glam::DVec3;
use rustial_engine::{VectorMeshData, VectorRenderMode};

// ---------------------------------------------------------------------------
// Fingerprint cache
// ---------------------------------------------------------------------------

fn quantise_origin(origin: DVec3) -> [i64; 3] {
    [
        (origin.x * 100.0) as i64,
        (origin.y * 100.0) as i64,
        (origin.z * 100.0) as i64,
    ]
}

/// Fingerprint of the current vector mesh data set.
///
/// When the fingerprint is unchanged between frames the sync system
/// takes the cheap reposition-only path instead of rebuilding all
/// entities.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct VectorFingerprint {
    /// Per-layer `(vertex_count, index_count, render_mode, fill_opacity_bits)`.
    layers: Vec<(usize, usize, u8, u32)>,
}

impl VectorFingerprint {
    fn from_meshes(meshes: &[VectorMeshData]) -> Self {
        Self {
            layers: meshes
                .iter()
                .map(|m| {
                    (
                        m.positions.len(),
                        m.indices.len(),
                        m.render_mode as u8,
                        m.fill_opacity.to_bits(),
                    )
                })
                .collect(),
        }
    }
}

/// Tracks the last-spawned vector fingerprint so steady-state frames
/// can skip the rebuild entirely.
#[derive(Resource, Default)]
pub struct VectorSyncState {
    fingerprint: VectorFingerprint,
    entity_count: usize,
    /// Quantised camera origin from the previous sync.
    last_origin: [i64; 3],
}

// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------

/// Synchronise engine vector meshes to Bevy mesh entities.
///
/// See the [module-level documentation](self) for the full entity
/// lifecycle, coordinate pipeline, and rebuild strategy.
pub fn sync_vectors(
    mut commands: Commands,
    state: Res<MapStateResource>,
    mut existing: Query<(Entity, &VectorEntity, &mut Transform)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut sync_state: ResMut<VectorSyncState>,
    detection: Res<FrameChangeDetection>,
) {
    // Whole-frame skip: if nothing in the engine changed, vector sync
    // has no work to do.
    if frame_unchanged(&detection, &state.0) {
        return;
    }

    let camera_origin: DVec3 = state.0.scene_world_origin();
    let vector_data = &state.0.vector_meshes();

    // -----------------------------------------------------------------
    // Fast path: no vector data -- despawn everything and return.
    // -----------------------------------------------------------------
    if vector_data.is_empty() {
        for (entity, _, _) in existing.iter() {
            commands.entity(entity).despawn();
            log::trace!("vector_sync: despawned (vector data empty)");
        }
        sync_state.fingerprint = VectorFingerprint::default();
        sync_state.entity_count = 0;
        sync_state.last_origin = [0; 3];
        return;
    }

    // -----------------------------------------------------------------
    // Stable path: fingerprint matches -- reposition only.
    //
    // The mesh vertices were baked relative to `spawn_origin`.  To
    // keep the entity at the correct world position when the camera
    // has since moved to `camera_origin`, we set:
    //
    //   Transform.translation = spawn_origin - camera_origin
    //
    // so that  vertex_world = vertex_mesh + translation
    //        = (world - spawn_origin) + (spawn_origin - camera_origin)
    //        = world - camera_origin
    // -----------------------------------------------------------------
    let new_fingerprint = VectorFingerprint::from_meshes(vector_data);
    if new_fingerprint == sync_state.fingerprint && sync_state.entity_count == vector_data.len() {
        let origin_key = quantise_origin(camera_origin);
        if origin_key == sync_state.last_origin {
            return;
        }

        for (_, vec_entity, mut transform) in existing.iter_mut() {
            let offset = vec_entity.spawn_origin - camera_origin;
            // Add a small Z offset to ensure vectors render cleanly above tiles.
            transform.translation =
                Vec3::new(offset.x as f32, offset.y as f32, offset.z as f32 + 0.1);
        }

        sync_state.last_origin = origin_key;
        return;
    }

    // -----------------------------------------------------------------
    // Structural change: fingerprint mismatch -- full despawn + respawn.
    // -----------------------------------------------------------------
    for (entity, _, _) in existing.iter() {
        commands.entity(entity).despawn();
        log::trace!("vector_sync: despawned (structural change)");
    }

    // -----------------------------------------------------------------
    // Spawn new vector mesh entities.
    // -----------------------------------------------------------------
    let mut spawned = 0usize;
    for (idx, vmesh) in vector_data.iter().enumerate() {
        // Guard: skip degenerate meshes with no renderable geometry.
        if vmesh.positions.is_empty() || vmesh.indices.is_empty() {
            log::trace!(
                "vector_sync: skipping degenerate mesh at index {} ({} verts, {} indices)",
                idx,
                vmesh.positions.len(),
                vmesh.indices.len(),
            );
            continue;
        }

        // Bake camera-relative positions into the mesh vertices.
        let positions: Vec<[f32; 3]> = vmesh
            .positions
            .iter()
            .map(|p| {
                [
                    (p[0] - camera_origin.x) as f32,
                    (p[1] - camera_origin.y) as f32,
                    (p[2] - camera_origin.z) as f32,
                ]
            })
            .collect();

        // Build the triangle mesh.
        let vertex_count = positions.len();
        let normals = if vmesh.has_normals() {
            // Use engine-supplied per-face normals (fill-extrusion).
            vmesh.normals.clone()
        } else {
            compute_vertex_normals(&positions, &vmesh.indices)
        };
        let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, Default::default());
        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
        mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vmesh.colors.clone());
        mesh.insert_indices(Indices::U32(vmesh.indices.clone()));

        let mesh_handle = meshes.add(mesh);

        let material_handle = material_for_mode(vmesh, &mut materials);

        commands.spawn((
            Mesh3d(mesh_handle),
            MeshMaterial3d(material_handle),
            Transform::from_xyz(0.0, 0.0, 0.1), // Elevate slightly above the Z=0 plane of basemap tiles
            Visibility::default(),
            VectorEntity {
                layer_name: format!("vector_{idx}"),
                spawn_origin: camera_origin,
                render_mode: vmesh.render_mode,
            },
        ));

        spawned += 1;
        log::trace!(
            "vector_sync: spawned mesh index {} ({} verts, {} tris)",
            idx,
            vertex_count,
            vmesh.indices.len() / 3,
        );
    }

    sync_state.fingerprint = new_fingerprint;
    sync_state.entity_count = spawned;
    sync_state.last_origin = quantise_origin(camera_origin);
}

// ---------------------------------------------------------------------------
// Mode-specific material factory
// ---------------------------------------------------------------------------

/// Create a Bevy [`StandardMaterial`] tuned for the given
/// [`VectorRenderMode`].
///
/// | Mode            | Lighting | Blend       | Notes |
/// |-----------------|----------|-------------|-------|
/// | Generic / Line  | unlit    | alpha blend | flat 2-D geometry |
/// | Fill            | unlit    | alpha blend | applies `fill_opacity` to base_color alpha |
/// | Circle          | unlit    | alpha blend | per-vertex colour carries circle colour |
/// | Heatmap         | unlit    | additive    | additive blending for heat accumulation |
/// | FillExtrusion   | **lit**  | alpha blend | directional-light shading on 3-D prisms |
/// | Symbol          | unlit    | alpha blend | SDF-based glyphs / icons |
fn material_for_mode(
    vmesh: &VectorMeshData,
    materials: &mut Assets<StandardMaterial>,
) -> Handle<StandardMaterial> {
    match vmesh.render_mode {
        VectorRenderMode::FillExtrusion => {
            // Lit material — receives scene lighting for 3-D fill-extrusion prisms.
            materials.add(StandardMaterial {
                base_color: Color::WHITE,
                alpha_mode: AlphaMode::Blend,
                double_sided: false,
                depth_bias: 0.0,
                perceptual_roughness: 0.95,
                ..Default::default()
            })
        }
        VectorRenderMode::Fill => {
            // Unlit flat fill.  Apply the layer's fill_opacity as the
            // base_color alpha so Bevy's alpha blend produces the correct
            // result even when per-vertex colours are opaque.
            materials.add(StandardMaterial {
                base_color: Color::linear_rgba(1.0, 1.0, 1.0, vmesh.fill_opacity),
                alpha_mode: AlphaMode::Blend,
                double_sided: true,
                depth_bias: 1000.0,
                perceptual_roughness: 0.95,
                unlit: true,
                ..Default::default()
            })
        }
        VectorRenderMode::Heatmap => {
            // Additive blending for heat accumulation.
            // Each point contributes its weighted Gaussian kernel
            // additively, which the final colour-ramp lookup maps to a
            // heatmap palette.
            materials.add(StandardMaterial {
                base_color: Color::WHITE,
                alpha_mode: AlphaMode::Add,
                double_sided: true,
                depth_bias: 1000.0,
                perceptual_roughness: 0.95,
                unlit: true,
                ..Default::default()
            })
        }
        // Generic | Line | Circle | Symbol — flat unlit with alpha blend.
        _ => materials.add(StandardMaterial {
            base_color: Color::WHITE,
            alpha_mode: AlphaMode::Blend,
            double_sided: true,
            depth_bias: 1000.0,
            perceptual_roughness: 0.95,
            unlit: true,
            ..Default::default()
        }),
    }
}

fn compute_vertex_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
    let mut accum = vec![Vec3::ZERO; positions.len()];

    for tri in indices.chunks_exact(3) {
        let ia = tri[0] as usize;
        let ib = tri[1] as usize;
        let ic = tri[2] as usize;

        if ia >= positions.len() || ib >= positions.len() || ic >= positions.len() {
            continue;
        }

        let a = Vec3::from_array(positions[ia]);
        let b = Vec3::from_array(positions[ib]);
        let c = Vec3::from_array(positions[ic]);
        let normal = (b - a).cross(c - a);

        if normal.length_squared() > 1e-12 {
            accum[ia] += normal;
            accum[ib] += normal;
            accum[ic] += normal;
        }
    }

    accum
        .into_iter()
        .map(|normal| {
            let normal = if normal.length_squared() > 1e-12 {
                normal.normalize()
            } else {
                Vec3::Z
            };
            [normal.x, normal.y, normal.z]
        })
        .collect()
}