hti-scene 0.0.2

Scene graph construction for the HTI rendering pipeline.
Documentation
use hti_core::*;
use std::collections::HashMap;

pub fn build_scene(
    nodes: &[LayoutNode],
    assets: &HashMap<String, Vec<u8>>,
    viewport: Rect,
    warnings: &mut Vec<RenderWarning>,
) -> Scene {
    let mut scene_nodes: Vec<SceneNode> = Vec::new();
    let mut clip_map: HashMap<String, u32> = HashMap::new();
    let mut clip_counter: u32 = 0;
    let mut gradient_defs: Vec<GradientDef> = Vec::new();
    let mut grad_counter: u32 = 0;
    let mut blur_filters: Vec<BlurFilterDef> = Vec::new();
    let mut filter_counter: u32 = 0;

    for node in nodes {
        let bounds = node.layout.bounds();

        // Culling: bỏ node ngoài viewport (chỉ cull non-div)
        if !bounds.intersects(&viewport) && node.dom.tag != DomTag::Div {
            continue;
        }

        // Resolve clip_id từ clip rect của node
        let clip_id = node.layout.clip.map(|clip_rect| {
            let key = format!(
                "{:.0},{:.0},{:.0},{:.0}",
                clip_rect.x, clip_rect.y, clip_rect.width, clip_rect.height
            );
            *clip_map.entry(key).or_insert_with(|| {
                let id = clip_counter;
                clip_counter += 1;
                scene_nodes.push(SceneNode::Clip(ClipSceneNode {
                    id,
                    rect: clip_rect,
                    border_radius: node.style.border_radius,
                }));
                id
            })
        });

        match node.dom.tag {
            DomTag::Img => {
                let src_key = node.dom.src.clone().unwrap_or_default();
                let (image_bytes, image_mime, intrinsic_w, intrinsic_h) =
                    if let Some(bytes) = assets.get(&src_key).filter(|b| !b.is_empty()) {
                        let (iw, ih) = read_image_size(bytes);
                        (bytes.clone(), guess_mime(&src_key), iw, ih)
                    } else {
                        if assets.get(&src_key).map_or(true, |b| b.is_empty()) {
                            warnings.push(RenderWarning::ImageDecodeFailed {
                                src: src_key.clone(),
                                reason: "asset not found or empty".to_string(),
                            });
                        }
                        (
                            placeholder_png(),
                            "image/png".to_string(),
                            bounds.width,
                            bounds.height,
                        )
                    };

                if let Some(shadow) = node.style.box_shadow {
                    push_shadow_rect(
                        &mut scene_nodes,
                        &mut blur_filters,
                        &mut filter_counter,
                        bounds,
                        shadow,
                        node.style.border_radius,
                        clip_id,
                    );
                }

                scene_nodes.push(SceneNode::Image(ImageSceneNode {
                    bounds,
                    src_key,
                    image_bytes,
                    image_mime,
                    object_fit: node.style.object_fit,
                    object_position: (node.style.object_position_x, node.style.object_position_y),
                    intrinsic_width: intrinsic_w,
                    intrinsic_height: intrinsic_h,
                    border_radius: node.style.border_radius,
                    opacity: node.style.opacity,
                    clip_id,
                    transform: node.style.transform,
                }));
            }

            DomTag::Div | DomTag::Span if node.dom.is_leaf_text() => {
                if let Some(metrics) = &node.text_metrics {
                    scene_nodes.push(SceneNode::Text(TextSceneNode {
                        bounds,
                        lines: metrics.lines.clone(),
                        font_size: node.style.font_size,
                        font_weight: node.style.font_weight,
                        font_family: node.style.font_family.clone(),
                        color: node.style.color,
                        line_height_px: metrics.line_height_px,
                        letter_spacing: node.style.letter_spacing,
                        text_align: node.style.text_align,
                        text_overflow: node.style.text_overflow,
                        white_space: node.style.white_space,
                        opacity: node.style.opacity,
                        clip_id,
                        transform: node.style.transform,
                    }));
                }
            }

            DomTag::Div | DomTag::Span => {
                let has_visual = !matches!(node.style.background, Background::None)
                    || node.style.border_width > 0.0
                    || node.style.box_shadow.is_some();
                if !has_visual {
                    continue;
                }

                // Gradient def — ID gắn trực tiếp vào RectSceneNode
                let (background, gradient_id) =
                    if let Background::LinearGradient(ref grad) = node.style.background {
                        let id = format!("grad{}", grad_counter);
                        grad_counter += 1;
                        gradient_defs.push(GradientDef {
                            id: id.clone(),
                            gradient: grad.clone(),
                        });
                        (node.style.background.clone(), Some(id))
                    } else {
                        (node.style.background.clone(), None)
                    };

                if let Some(shadow) = node.style.box_shadow {
                    push_shadow_rect(
                        &mut scene_nodes,
                        &mut blur_filters,
                        &mut filter_counter,
                        bounds,
                        shadow,
                        node.style.border_radius,
                        clip_id,
                    );
                }

                scene_nodes.push(SceneNode::Rect(RectSceneNode {
                    bounds,
                    background,
                    gradient_id,
                    border_width: node.style.border_width,
                    border_color: node.style.border_color,
                    border_radius: node.style.border_radius,
                    opacity: node.style.opacity,
                    clip_id,
                    transform: node.style.transform,
                    backdrop_blur_radius: node.style.backdrop_blur_radius,
                    blur_radius: 0.0,
                    filter_id: None,
                }));
            }
        }
    }

    let content_size = nodes.iter().fold(Size::new(0.0, 0.0), |acc, n| {
        Size::new(
            f32::max(acc.width, n.layout.x + n.layout.width),
            f32::max(acc.height, n.layout.y + n.layout.height),
        )
    });

    Scene {
        nodes: scene_nodes,
        gradient_defs,
        blur_filters,
        viewport,
        content_size,
    }
}

fn push_shadow_rect(
    nodes: &mut Vec<SceneNode>,
    blur_filters: &mut Vec<BlurFilterDef>,
    filter_counter: &mut u32,
    bounds: Rect,
    shadow: BoxShadow,
    border_radius: f32,
    clip_id: Option<u32>,
) {
    let shadow_bounds = Rect::new(
        bounds.x + shadow.offset_x - shadow.spread_radius,
        bounds.y + shadow.offset_y - shadow.spread_radius,
        bounds.width + shadow.spread_radius * 2.0,
        bounds.height + shadow.spread_radius * 2.0,
    );

    // Tạo blur filter nếu có blur_radius > 0
    let filter_id = if shadow.blur_radius > 0.0 {
        let id = format!("blur{}", filter_counter);
        *filter_counter += 1;
        blur_filters.push(BlurFilterDef {
            id: id.clone(),
            std_deviation: shadow.blur_radius / 2.0, // CSS blur → SVG stdDeviation
        });
        Some(id)
    } else {
        None
    };

    nodes.push(SceneNode::Rect(RectSceneNode {
        bounds: shadow_bounds,
        background: Background::Color(shadow.color),
        gradient_id: None,
        border_width: 0.0,
        border_color: Color::TRANSPARENT,
        border_radius: border_radius + shadow.spread_radius,
        opacity: shadow.color.a as f32 / 255.0,
        clip_id,
        transform: Transform::default(),
        backdrop_blur_radius: 0.0,
        blur_radius: shadow.blur_radius,
        filter_id,
    }));
}

/// Đọc intrinsic size của ảnh từ bytes (chỉ decode header).
fn read_image_size(bytes: &[u8]) -> (f32, f32) {
    use image::ImageReader;
    use std::io::Cursor;
    if let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() {
        if let Ok((w, h)) = reader.into_dimensions() {
            return (w as f32, h as f32);
        }
    }
    (0.0, 0.0) // unknown → 0 means "use bounds"
}

fn guess_mime(src: &str) -> String {
    if src.ends_with(".png") {
        "image/png".into()
    } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
        "image/jpeg".into()
    } else if src.ends_with(".webp") {
        "image/webp".into()
    } else if src.ends_with(".gif") {
        "image/gif".into()
    } else {
        "image/png".into()
    }
}

fn placeholder_png() -> Vec<u8> {
    // 1×1 grey PNG
    vec![
        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
        0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xd8,
        0xd8, 0xd8, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
        0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
    ]
}