scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use std::collections::BTreeMap;

use serde_json::json;

use crate::scene::{SceneInspectionReportV1, SceneNodeInspectionV1, Transform};

use super::super::{
    VisibilityDiagnosisEvidenceV1, VisibilityDiagnosisFixV1, VisibilityDiagnosisOptions,
    VisibilityDiagnosisReasonV1,
};
use super::reason::{ReasonSpec, push_evidence, push_fix, push_reason};

pub(super) fn diagnose_node(
    node: &SceneNodeInspectionV1,
    inspection: &SceneInspectionReportV1,
    reasons: &mut Vec<VisibilityDiagnosisReasonV1>,
    fixes: &mut Vec<VisibilityDiagnosisFixV1>,
    evidence: &mut Vec<VisibilityDiagnosisEvidenceV1>,
    options: VisibilityDiagnosisOptions,
) {
    diagnose_single_node(node, inspection, reasons, fixes, evidence, options);
    for descendant in descendants_of(node.handle, inspection) {
        diagnose_single_node(descendant, inspection, reasons, fixes, evidence, options);
    }
}

fn diagnose_single_node(
    node: &SceneNodeInspectionV1,
    inspection: &SceneInspectionReportV1,
    reasons: &mut Vec<VisibilityDiagnosisReasonV1>,
    fixes: &mut Vec<VisibilityDiagnosisFixV1>,
    evidence: &mut Vec<VisibilityDiagnosisEvidenceV1>,
    options: VisibilityDiagnosisOptions,
) {
    let hidden_ancestor = hidden_ancestor(node, inspection);
    if !node.visible {
        push_reason(
            reasons,
            ReasonSpec {
                code: "node_hidden",
                severity: "error",
                confidence: "high",
                auto_fixable: true,
                affected_handles: vec![node.handle],
                message: "target node is hidden",
            },
        );
        push_fix(
            fixes,
            "set_visible",
            Some(node.handle),
            Some(json!({
                "visibility": [
                    {
                        "node": node.handle,
                        "visible": true
                    }
                ]
            })),
            "content",
            "set the target node visible, then render and diagnose again",
        );
    }

    if let Some(ancestor) = hidden_ancestor {
        push_reason(
            reasons,
            ReasonSpec {
                code: "parent_hidden",
                severity: "error",
                confidence: "high",
                auto_fixable: true,
                affected_handles: vec![node.handle, ancestor.handle],
                message: "an ancestor of the target node is hidden",
            },
        );
        push_fix(
            fixes,
            "set_visible",
            Some(ancestor.handle),
            Some(json!({
                "visibility": [
                    {
                        "node": ancestor.handle,
                        "visible": true
                    }
                ]
            })),
            "content",
            "set the hidden ancestor visible, then render and diagnose again",
        );
        if options.detail_enabled() {
            push_evidence(
                evidence,
                "visibility_ancestor",
                Some(ancestor.handle),
                "hidden ancestor prevents the target subtree from drawing",
                Some(json!({
                    "target": node.handle,
                    "ancestor": ancestor.handle,
                    "ancestor_kind": ancestor.kind
                })),
            );
        }
    }

    if !transform_is_finite(node.local_transform) || !transform_is_finite(node.world_transform) {
        push_reason(
            reasons,
            ReasonSpec {
                code: "nan_transform",
                severity: "error",
                confidence: "high",
                auto_fixable: true,
                affected_handles: vec![node.handle],
                message: "target node has a non-finite transform component",
            },
        );
        push_fix(
            fixes,
            "set_transform",
            Some(node.handle),
            Some(json!({
                "transforms": [
                    {
                        "node": node.handle,
                        "transform": Transform::IDENTITY
                    }
                ]
            })),
            "content",
            "replace the non-finite transform with a finite transform, then diagnose again",
        );
    }

    if is_zero_scale(node.local_transform) || is_zero_scale(node.world_transform) {
        push_reason(
            reasons,
            ReasonSpec {
                code: "zero_scale",
                severity: "error",
                confidence: "high",
                auto_fixable: true,
                affected_handles: vec![node.handle],
                message: "target node has a zero scale component",
            },
        );
        push_fix(
            fixes,
            "set_transform",
            Some(node.handle),
            Some(json!({
                "transforms": [
                    {
                        "node": node.handle,
                        "transform": {
                            "translation": [
                                node.local_transform.translation.x,
                                node.local_transform.translation.y,
                                node.local_transform.translation.z
                            ],
                            "rotation": [
                                node.local_transform.rotation.x,
                                node.local_transform.rotation.y,
                                node.local_transform.rotation.z,
                                node.local_transform.rotation.w
                            ],
                            "scale": [1.0, 1.0, 1.0]
                        }
                    }
                ]
            })),
            "content",
            "replace the zero scale with a visible non-zero scale, then diagnose again",
        );
    }

    diagnose_draw_and_material(node, inspection, reasons, fixes);

    if options.detail_enabled() {
        push_evidence(
            evidence,
            "node_state",
            Some(node.handle),
            "inspection row used for target diagnosis",
            Some(json!({
                "visible": node.visible,
                "kind": node.kind,
                "local_scale": [
                    node.local_transform.scale.x,
                    node.local_transform.scale.y,
                    node.local_transform.scale.z
                ],
                "world_scale": [
                    node.world_transform.scale.x,
                    node.world_transform.scale.y,
                    node.world_transform.scale.z
                ]
            })),
        );
    }
}

fn descendants_of(root: u64, inspection: &SceneInspectionReportV1) -> Vec<&SceneNodeInspectionV1> {
    let mut descendants = Vec::new();
    for node in &inspection.nodes {
        let mut current = node.parent;
        while let Some(parent) = current {
            if parent == root {
                descendants.push(node);
                break;
            }
            current = inspection
                .nodes
                .iter()
                .find(|candidate| candidate.handle == parent)
                .and_then(|candidate| candidate.parent);
        }
    }
    descendants
}

fn diagnose_draw_and_material(
    node: &SceneNodeInspectionV1,
    inspection: &SceneInspectionReportV1,
    reasons: &mut Vec<VisibilityDiagnosisReasonV1>,
    fixes: &mut Vec<VisibilityDiagnosisFixV1>,
) {
    let draw = inspection
        .draw_list
        .iter()
        .find(|draw| draw.node == node.handle);
    let hidden_by_visibility = !node.visible || hidden_ancestor(node, inspection).is_some();
    if draw.is_none()
        && !hidden_by_visibility
        && transform_is_finite(node.local_transform)
        && transform_is_finite(node.world_transform)
        && !is_zero_scale(node.local_transform)
        && !is_zero_scale(node.world_transform)
    {
        if inspection.active_camera.is_some() && node.layer_mask != u64::MAX {
            push_reason(
                reasons,
                ReasonSpec {
                    code: "layer_masked",
                    severity: "error",
                    confidence: "medium",
                    auto_fixable: false,
                    affected_handles: vec![node.handle],
                    message: "target node is excluded from the active camera layer mask",
                },
            );
            push_fix(
                fixes,
                "set_layer_mask",
                Some(node.handle),
                None,
                "content",
                "put the target node on a layer visible to the active camera, then render again",
            );
        } else if node.kind.eq_ignore_ascii_case("mesh")
            || node.kind.eq_ignore_ascii_case("instance_set")
            || node.kind.eq_ignore_ascii_case("model")
        {
            push_reason(
                reasons,
                ReasonSpec {
                    code: "missing_geometry",
                    severity: "error",
                    confidence: "medium",
                    auto_fixable: false,
                    affected_handles: vec![node.handle],
                    message: "target drawable has no geometry in the inspection draw list",
                },
            );
            push_fix(
                fixes,
                "inspect_assets",
                Some(node.handle),
                None,
                "content",
                "inspect the Assets store used for diagnosis and prepare with the matching asset handles",
            );
        }
    }

    let Some(draw) = draw else {
        return;
    };
    let Some(material) = draw.material.as_ref().or(node.material.as_ref()) else {
        push_reason(
            reasons,
            ReasonSpec {
                code: "missing_material_upload",
                severity: "error",
                confidence: "medium",
                auto_fixable: false,
                affected_handles: vec![node.handle],
                message: "target drawable has no material preview in the inspection draw list",
            },
        );
        push_fix(
            fixes,
            "inspect_assets",
            Some(node.handle),
            None,
            "content",
            "inspect the Assets store used for diagnosis and ensure the material handle resolves",
        );
        return;
    };

    if material.base_color.a <= 1.0e-6 {
        push_reason(
            reasons,
            ReasonSpec {
                code: "alpha_zero",
                severity: "error",
                confidence: "high",
                auto_fixable: false,
                affected_handles: vec![node.handle],
                message: "target material base color alpha is zero",
            },
        );
        push_fix(
            fixes,
            "set_material_alpha",
            Some(node.handle),
            None,
            "content",
            "raise the target material alpha or replace the material, then render again",
        );
    } else if material.alpha_mode != "opaque" {
        push_reason(
            reasons,
            ReasonSpec {
                code: "transparent_material",
                severity: "warning",
                confidence: "medium",
                auto_fixable: false,
                affected_handles: vec![node.handle],
                message: "target material uses a transparent alpha mode",
            },
        );
    }
}

fn hidden_ancestor<'a>(
    node: &SceneNodeInspectionV1,
    inspection: &'a SceneInspectionReportV1,
) -> Option<&'a SceneNodeInspectionV1> {
    let nodes = nodes_by_handle(inspection);
    let mut current = node.parent;
    while let Some(handle) = current {
        let ancestor = nodes.get(&handle).copied()?;
        if !ancestor.visible {
            return Some(ancestor);
        }
        current = ancestor.parent;
    }
    None
}

fn nodes_by_handle(inspection: &SceneInspectionReportV1) -> BTreeMap<u64, &SceneNodeInspectionV1> {
    inspection
        .nodes
        .iter()
        .map(|node| (node.handle, node))
        .collect()
}

fn is_zero_scale(transform: Transform) -> bool {
    const EPSILON: f32 = 1.0e-6;
    transform.scale.x.abs() <= EPSILON
        || transform.scale.y.abs() <= EPSILON
        || transform.scale.z.abs() <= EPSILON
}

fn transform_is_finite(transform: Transform) -> bool {
    transform.translation.is_finite()
        && transform.rotation.is_finite()
        && transform.scale.is_finite()
}