scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use serde::{Deserialize, Serialize, Serializer};

use super::{SceneHostCore, SceneHostError, SceneHostErrorCode};
use crate::{AssetFetcher, MeasurementKind, MeasurementOverlay, UnitFormat, Vec3};

pub const SCENE_HOST_MEASUREMENT_OVERLAY_SCHEMA_V1: &str =
    "scena.scene_host_measurement_overlay.v1";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SceneHostMeasurementOverlayReportV1 {
    pub schema: String,
    pub id: String,
    pub kind: String,
    #[serde(serialize_with = "serialize_round3_f32")]
    pub value: f32,
    pub formatted_value: String,
    pub line_node: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label_text: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label_projection: Option<SceneHostMeasurementLabelProjectionV1>,
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SceneHostMeasurementLabelProjectionV1 {
    #[serde(serialize_with = "serialize_round3_f32")]
    pub x_css_px: f32,
    #[serde(serialize_with = "serialize_round3_f32")]
    pub y_css_px: f32,
    pub visible: bool,
}

impl<F: AssetFetcher> SceneHostCore<F> {
    pub fn add_distance_measurement_json(
        &mut self,
        id: &str,
        start: Vec3,
        end: Vec3,
        label: Option<&str>,
        unit: &str,
        precision: u8,
    ) -> Result<String, SceneHostError> {
        let units = measurement_units(unit, precision)?;
        let mut overlay = MeasurementOverlay::distance(id, start, end).with_units(units);
        if let Some(label) = label {
            overlay = overlay.with_label(label);
        }
        let report = self.scene.add_measurement_overlay(&self.assets, overlay)?;
        let label_node = self
            .scene
            .measurement_overlay_state(&report.id)
            .and_then(|state| state.label_node());
        let line_node = self.register_node(report.line_node);
        if let Some(label_node) = label_node {
            self.register_node(label_node);
        }
        let label_text = label.map(|label| format!("{label}: {}", report.formatted_value));
        let label_projection = label
            .map(|_| self.project_measurement_label((start + end) * 0.5))
            .transpose()?;
        let report = SceneHostMeasurementOverlayReportV1 {
            schema: SCENE_HOST_MEASUREMENT_OVERLAY_SCHEMA_V1.to_owned(),
            id: report.id,
            kind: measurement_kind_name(report.kind).to_owned(),
            value: round3(report.value),
            formatted_value: report.formatted_value,
            line_node,
            label_text,
            label_projection,
        };
        serde_json::to_string(&report).map_err(|error| {
            SceneHostError::new(
                SceneHostErrorCode::Inspect,
                format!("measurement overlay serialization failed: {error}"),
            )
        })
    }

    fn project_measurement_label(
        &self,
        world_position: Vec3,
    ) -> Result<SceneHostMeasurementLabelProjectionV1, SceneHostError> {
        let width = self.viewport.logical_width().round().max(1.0) as u32;
        let height = self.viewport.logical_height().round().max(1.0) as u32;
        let projected =
            self.scene
                .project_world_point(self.active_camera, world_position, width, height)?;
        let Some(projected) = projected else {
            return Ok(SceneHostMeasurementLabelProjectionV1 {
                x_css_px: 0.0,
                y_css_px: 0.0,
                visible: false,
            });
        };
        Ok(SceneHostMeasurementLabelProjectionV1 {
            x_css_px: round3(projected.x),
            y_css_px: round3(projected.y),
            visible: projected.ndc_x.abs() <= 1.0 && projected.ndc_y.abs() <= 1.0,
        })
    }
}

fn measurement_units(unit: &str, precision: u8) -> Result<UnitFormat, SceneHostError> {
    match unit {
        "m" | "meter" | "meters" => Ok(UnitFormat::meters().with_precision(precision)),
        "mm" | "millimeter" | "millimeters" => {
            Ok(UnitFormat::millimeters().with_precision(precision))
        }
        "" | "unit" | "units" => Ok(UnitFormat::custom(1.0, "", precision)),
        _ => Err(SceneHostError::new(
            SceneHostErrorCode::InvalidInput,
            format!("unsupported distance measurement unit {unit:?}; expected m, mm, or unit"),
        )),
    }
}

fn measurement_kind_name(kind: MeasurementKind) -> &'static str {
    match kind {
        MeasurementKind::Distance => "distance",
        MeasurementKind::Angle => "angle",
        MeasurementKind::BoundsDimension => "bounds_dimension",
    }
}

fn round3(value: f32) -> f32 {
    if !value.is_finite() {
        return 0.0;
    }
    let rounded = (value * 1000.0).round() / 1000.0;
    if rounded == 0.0 { 0.0 } else { rounded }
}

fn serialize_round3_f32<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_f64(stable_round3(*value))
}

fn stable_round3(value: f32) -> f64 {
    if !value.is_finite() {
        return 0.0;
    }
    let rounded = (f64::from(value) * 1000.0).round() / 1000.0;
    if rounded == 0.0 { 0.0 } else { rounded }
}