scena 1.7.1

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

use super::{SceneHostCore, SceneHostError, SceneHostErrorCode};
#[cfg(target_arch = "wasm32")]
use crate::OrbitControlAction;
use crate::{
    AssetFetcher, CameraKey, FramingOptions, LookupError,
    OrbitControlAction as HostOrbitControlAction, OrbitControls, PointerButton, PointerEvent,
    PointerEventKind, Scene, Vec3,
};

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SceneHostCameraState {
    pub target: Vec3,
    pub distance: f32,
    pub yaw_radians: f32,
    pub pitch_radians: f32,
}

impl SceneHostCameraState {
    pub(crate) fn from_controls(controls: &OrbitControls) -> Self {
        Self {
            target: controls.target(),
            distance: controls.distance(),
            yaw_radians: controls.yaw_radians(),
            pitch_radians: controls.pitch_radians(),
        }
    }

    pub(crate) fn validate(self) -> Result<(), &'static str> {
        if !self.target.to_array().into_iter().all(f32::is_finite) {
            return Err("camera target must contain finite values");
        }
        if !self.distance.is_finite() || self.distance <= 0.0 {
            return Err("camera distance must be finite and greater than zero");
        }
        if !self.yaw_radians.is_finite() {
            return Err("camera yaw must be finite");
        }
        if !self.pitch_radians.is_finite() {
            return Err("camera pitch must be finite");
        }
        Ok(())
    }

    pub(crate) fn into_controls(self) -> OrbitControls {
        OrbitControls::new(self.target, self.distance)
            .with_angles(self.yaw_radians, self.pitch_radians)
    }
}

#[cfg(target_arch = "wasm32")]
pub(crate) const fn orbit_action_name(action: OrbitControlAction) -> &'static str {
    match action {
        OrbitControlAction::None => "none",
        OrbitControlAction::BeginOrbit => "begin_orbit",
        OrbitControlAction::Orbit => "orbit",
        OrbitControlAction::Pan => "pan",
        OrbitControlAction::Zoom => "zoom",
        OrbitControlAction::End => "end",
    }
}

impl<F: AssetFetcher> SceneHostCore<F> {
    pub fn camera_state(&self) -> SceneHostCameraState {
        SceneHostCameraState::from_controls(&self.camera_controls)
    }

    pub fn get_camera(&self) -> SceneHostCameraState {
        self.camera_state()
    }

    pub fn camera_json(&self) -> Result<String, SceneHostError> {
        serde_json::to_string(&self.camera_state()).map_err(|error| {
            SceneHostError::new(
                SceneHostErrorCode::Inspect,
                format!("camera state serialization failed: {error}"),
            )
        })
    }

    pub fn set_camera(&mut self, state: SceneHostCameraState) -> Result<(), SceneHostError> {
        state.validate().map_err(|message| {
            SceneHostError::new(SceneHostErrorCode::InvalidInput, message.to_owned())
        })?;
        let controls = state.into_controls();
        controls.apply_to_scene(&mut self.scene, self.active_camera)?;
        self.camera_controls = controls;
        Ok(())
    }

    pub fn set_camera_json(&mut self, json: &str) -> Result<(), SceneHostError> {
        let state: SceneHostCameraState = serde_json::from_str(json).map_err(|error| {
            SceneHostError::new(
                SceneHostErrorCode::InvalidInput,
                format!("invalid camera JSON: {error}"),
            )
        })?;
        self.set_camera(state)
    }

    pub fn camera_pointer_down(
        &mut self,
        x: f32,
        y: f32,
        button: PointerButton,
    ) -> Result<HostOrbitControlAction, SceneHostError> {
        self.apply_camera_pointer(PointerEvent {
            kind: PointerEventKind::Pressed,
            position: (x, y),
            button: Some(button),
            delta: (0.0, 0.0),
            scroll_delta: 0.0,
        })
    }

    pub fn camera_pointer_move(
        &mut self,
        x: f32,
        y: f32,
        delta_x: f32,
        delta_y: f32,
    ) -> Result<HostOrbitControlAction, SceneHostError> {
        self.apply_camera_pointer(PointerEvent::moved(x, y, delta_x, delta_y))
    }

    pub fn camera_pointer_up(
        &mut self,
        x: f32,
        y: f32,
    ) -> Result<HostOrbitControlAction, SceneHostError> {
        self.apply_camera_pointer(PointerEvent::released(x, y))
    }

    pub fn camera_wheel(
        &mut self,
        x: f32,
        y: f32,
        delta_y: f32,
    ) -> Result<HostOrbitControlAction, SceneHostError> {
        self.apply_camera_pointer(PointerEvent::wheel(x, y, delta_y))
    }

    pub fn frame_node(&mut self, node: u64) -> Result<(), SceneHostError> {
        let node = self.resolve_node(node)?;
        let bounds = self
            .scene
            .node_world_bounds(node, &self.assets)?
            .ok_or(LookupError::ImportHasNoBounds)?;
        self.scene.frame(self.active_camera, bounds)?;
        self.camera_controls =
            controls_from_scene_camera(&self.scene, self.active_camera, bounds.center())?;
        Ok(())
    }

    pub fn frame_node_product_view(&mut self, node: u64) -> Result<(), SceneHostError> {
        self.frame_node_with_preset(node, "product_viewer_default")
    }

    pub fn frame_node_with_preset(
        &mut self,
        node: u64,
        preset: &str,
    ) -> Result<(), SceneHostError> {
        let node = self.resolve_node(node)?;
        let bounds = self
            .scene
            .node_world_bounds(node, &self.assets)?
            .ok_or(LookupError::ImportHasNoBounds)?;
        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 (options, fill) = match preset {
            "cell_overview" => (FramingOptions::new().top(), 0.72),
            "operator_review_default" => (
                FramingOptions::new().orbit(35.0_f32.to_radians(), 14.0_f32.to_radians()),
                0.78,
            ),
            "product_viewer_default" => (FramingOptions::new().three_quarter_front_right(), 0.72),
            _ => {
                return Err(SceneHostError::new(
                    SceneHostErrorCode::InvalidInput,
                    format!("unsupported SceneHost camera preset {preset}"),
                ));
            }
        };
        let framing = self.scene.frame_bounds(
            self.active_camera,
            bounds,
            options.fill(fill).margin_px(48.0).viewport(width, height),
        )?;
        self.camera_controls = OrbitControls::from_framing(framing);
        Ok(())
    }

    pub fn frame_all(&mut self) -> Result<(), SceneHostError> {
        let bounds = self
            .scene
            .node_world_bounds(self.scene.root(), &self.assets)?
            .ok_or(LookupError::ImportHasNoBounds)?;
        self.scene.frame(self.active_camera, bounds)?;
        self.camera_controls =
            controls_from_scene_camera(&self.scene, self.active_camera, bounds.center())?;
        Ok(())
    }

    fn apply_camera_pointer(
        &mut self,
        event: PointerEvent,
    ) -> Result<HostOrbitControlAction, SceneHostError> {
        let action = self.camera_controls.handle_pointer(event);
        if matches!(
            action,
            HostOrbitControlAction::Orbit
                | HostOrbitControlAction::Pan
                | HostOrbitControlAction::Zoom
        ) {
            self.camera_controls
                .apply_to_scene(&mut self.scene, self.active_camera)?;
        }
        Ok(action)
    }
}

pub(super) fn controls_from_scene_camera(
    scene: &Scene,
    camera: CameraKey,
    target: Vec3,
) -> Result<OrbitControls, SceneHostError> {
    let camera_node = scene
        .camera_node(camera)
        .ok_or(LookupError::CameraNotFound(camera))?;
    let camera_world = scene
        .world_transform(camera_node)
        .ok_or(LookupError::NodeNotFound(camera_node))?;
    let offset = camera_world.translation - target;
    let distance = offset.length().max(0.0001);
    let yaw_radians = offset.x.atan2(offset.z);
    let pitch_radians = (offset.y / distance).clamp(-1.0, 1.0).asin();
    Ok(OrbitControls::new(target, distance).with_angles(yaw_radians, pitch_radians))
}