bevy_symbios_shape 0.5.0

Bevy integration for Symbios Shape.
Documentation
//! Coordinate system translation between `symbios-shape` and Bevy.
//!
//! `symbios-shape` uses double-precision (`f64`) math and defines [`Scope`] by its
//! **minimum corner** (local origin at position (0,0,0) extending positively to `size`).
//!
//! Bevy assets (e.g. `Cuboid`, GLTF meshes) are anchored at their **centroid**.
//! This module provides the single canonical translation function that handles both
//! the precision downcast (`f64 → f32`) and the corner-to-centroid offset.

use bevy::math::{Quat, Vec3};
use bevy::prelude::Transform;
use symbios_shape::Scope;

/// Clamps `v` away from zero while preserving its sign.
///
/// The minimum of `1e-3` is chosen for depth-buffer safety: at 1e-5 world units,
/// a "flat" scope would z-fight with adjacent faces at typical camera distances
/// (depth precision ≈ 6e-5 at distance 10 for near=0.1/far=1000).  1e-3 keeps
/// fighting beyond ~170 world units, acceptable for panel/face geometry.
fn nonzero(v: f32) -> f32 {
    if v >= 0.0 { v.max(1e-3) } else { v.min(-1e-3) }
}

/// Converts a `symbios-shape` [`Scope`] into a Bevy [`Transform`].
///
/// Two adjustments are made:
///
/// 1. **Precision downcast**: `DVec3`/`DQuat` (`f64`) → `Vec3`/`Quat` (`f32`).
/// 2. **Corner-to-centroid offset**: The scope's `position` is the world-space
///    minimum corner. Bevy meshes are centered at the origin, so we compute:
///    ```text
///    center = position + rotation * (size * 0.5)
///    ```
///
/// The resulting `Transform` places the mesh centroid at the correct world
/// position with the correct orientation and scale.
pub fn scope_to_transform(scope: &Scope) -> Transform {
    // Compute centroid in double precision, then downcast.
    let half_size = scope.size * 0.5;
    let center_d = scope.position + scope.rotation * half_size;

    let translation = Vec3::new(center_d.x as f32, center_d.y as f32, center_d.z as f32);

    // DQuat → Quat (f32). Bevy's Quat::from_xyzw expects (x, y, z, w).
    let dq = scope.rotation;
    let rotation = Quat::from_xyzw(dq.x as f32, dq.y as f32, dq.z as f32, dq.w as f32).normalize();

    // Scale encodes the full extents so that a unit mesh fills the scope exactly.
    // Preserve the sign: negative sizes are valid in CGA grammars (they mirror geometry).
    // We only guard against an absolute value of zero (which would collapse the mesh).
    let scale = Vec3::new(
        nonzero(scope.size.x as f32),
        nonzero(scope.size.y as f32),
        nonzero(scope.size.z as f32),
    );

    Transform {
        translation,
        rotation,
        scale,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use symbios_shape::{Quat as DQuat, Scope, Vec3 as DVec3};

    #[test]
    fn identity_scope_centroid_at_half_size() {
        let scope = Scope::new(DVec3::ZERO, DQuat::IDENTITY, DVec3::new(4.0, 6.0, 2.0));
        let t = scope_to_transform(&scope);
        assert!((t.translation - Vec3::new(2.0, 3.0, 1.0)).length() < 1e-5);
        assert!((t.scale - Vec3::new(4.0, 6.0, 2.0)).length() < 1e-5);
    }

    #[test]
    fn offset_scope_centroid_is_correct() {
        // Scope at (10, 0, 0) with size (2, 4, 2); centroid should be at (11, 2, 1).
        let scope = Scope::new(
            DVec3::new(10.0, 0.0, 0.0),
            DQuat::IDENTITY,
            DVec3::new(2.0, 4.0, 2.0),
        );
        let t = scope_to_transform(&scope);
        assert!((t.translation - Vec3::new(11.0, 2.0, 1.0)).length() < 1e-5);
    }

    #[test]
    fn rotation_is_preserved() {
        use std::f64::consts::FRAC_PI_2;
        let rot = DQuat::from_axis_angle(DVec3::Y, FRAC_PI_2);
        let scope = Scope::new(DVec3::ZERO, rot, DVec3::new(2.0, 2.0, 2.0));
        let t = scope_to_transform(&scope);
        // Right-hand rule: +90° around Y maps +Z → +X.
        let forward = t.rotation * Vec3::Z;
        assert!(
            (forward - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5,
            "expected +X, got {:?}",
            forward
        );
    }
}