1use hti_core::*;
2use std::collections::HashMap;
3
4pub fn build_scene(
5 nodes: &[LayoutNode],
6 assets: &HashMap<String, Vec<u8>>,
7 viewport: Rect,
8 warnings: &mut Vec<RenderWarning>,
9) -> Scene {
10 let mut scene_nodes: Vec<SceneNode> = Vec::new();
11 let mut clip_map: HashMap<String, u32> = HashMap::new();
12 let mut clip_counter: u32 = 0;
13 let mut gradient_defs: Vec<GradientDef> = Vec::new();
14 let mut grad_counter: u32 = 0;
15 let mut blur_filters: Vec<BlurFilterDef> = Vec::new();
16 let mut filter_counter: u32 = 0;
17
18 for node in nodes {
19 let bounds = node.layout.bounds();
20
21 if !bounds.intersects(&viewport) && node.dom.tag != DomTag::Div {
23 continue;
24 }
25
26 let clip_id = node.layout.clip.map(|clip_rect| {
28 let key = format!(
29 "{:.0},{:.0},{:.0},{:.0}",
30 clip_rect.x, clip_rect.y, clip_rect.width, clip_rect.height
31 );
32 *clip_map.entry(key).or_insert_with(|| {
33 let id = clip_counter;
34 clip_counter += 1;
35 scene_nodes.push(SceneNode::Clip(ClipSceneNode {
36 id,
37 rect: clip_rect,
38 border_radius: node.style.border_radius,
39 }));
40 id
41 })
42 });
43
44 match node.dom.tag {
45 DomTag::Img => {
46 let src_key = node.dom.src.clone().unwrap_or_default();
47 let (image_bytes, image_mime, intrinsic_w, intrinsic_h) =
48 if let Some(bytes) = assets.get(&src_key).filter(|b| !b.is_empty()) {
49 let (iw, ih) = read_image_size(bytes);
50 (bytes.clone(), guess_mime(&src_key), iw, ih)
51 } else {
52 if assets.get(&src_key).map_or(true, |b| b.is_empty()) {
53 warnings.push(RenderWarning::ImageDecodeFailed {
54 src: src_key.clone(),
55 reason: "asset not found or empty".to_string(),
56 });
57 }
58 (
59 placeholder_png(),
60 "image/png".to_string(),
61 bounds.width,
62 bounds.height,
63 )
64 };
65
66 if let Some(shadow) = node.style.box_shadow {
67 push_shadow_rect(
68 &mut scene_nodes,
69 &mut blur_filters,
70 &mut filter_counter,
71 bounds,
72 shadow,
73 node.style.border_radius,
74 clip_id,
75 );
76 }
77
78 scene_nodes.push(SceneNode::Image(ImageSceneNode {
79 bounds,
80 src_key,
81 image_bytes,
82 image_mime,
83 object_fit: node.style.object_fit,
84 object_position: (node.style.object_position_x, node.style.object_position_y),
85 intrinsic_width: intrinsic_w,
86 intrinsic_height: intrinsic_h,
87 border_radius: node.style.border_radius,
88 opacity: node.style.opacity,
89 clip_id,
90 transform: node.style.transform,
91 }));
92 }
93
94 DomTag::Div | DomTag::Span if node.dom.is_leaf_text() => {
95 if let Some(metrics) = &node.text_metrics {
96 scene_nodes.push(SceneNode::Text(TextSceneNode {
97 bounds,
98 lines: metrics.lines.clone(),
99 font_size: node.style.font_size,
100 font_weight: node.style.font_weight,
101 font_family: node.style.font_family.clone(),
102 color: node.style.color,
103 line_height_px: metrics.line_height_px,
104 letter_spacing: node.style.letter_spacing,
105 text_align: node.style.text_align,
106 text_overflow: node.style.text_overflow,
107 white_space: node.style.white_space,
108 opacity: node.style.opacity,
109 clip_id,
110 transform: node.style.transform,
111 }));
112 }
113 }
114
115 DomTag::Div | DomTag::Span => {
116 let has_visual = !matches!(node.style.background, Background::None)
117 || node.style.border_width > 0.0
118 || node.style.box_shadow.is_some();
119 if !has_visual {
120 continue;
121 }
122
123 let (background, gradient_id) =
125 if let Background::LinearGradient(ref grad) = node.style.background {
126 let id = format!("grad{}", grad_counter);
127 grad_counter += 1;
128 gradient_defs.push(GradientDef {
129 id: id.clone(),
130 gradient: grad.clone(),
131 });
132 (node.style.background.clone(), Some(id))
133 } else {
134 (node.style.background.clone(), None)
135 };
136
137 if let Some(shadow) = node.style.box_shadow {
138 push_shadow_rect(
139 &mut scene_nodes,
140 &mut blur_filters,
141 &mut filter_counter,
142 bounds,
143 shadow,
144 node.style.border_radius,
145 clip_id,
146 );
147 }
148
149 scene_nodes.push(SceneNode::Rect(RectSceneNode {
150 bounds,
151 background,
152 gradient_id,
153 border_width: node.style.border_width,
154 border_color: node.style.border_color,
155 border_radius: node.style.border_radius,
156 opacity: node.style.opacity,
157 clip_id,
158 transform: node.style.transform,
159 backdrop_blur_radius: node.style.backdrop_blur_radius,
160 blur_radius: 0.0,
161 filter_id: None,
162 }));
163 }
164 }
165 }
166
167 let content_size = nodes.iter().fold(Size::new(0.0, 0.0), |acc, n| {
168 Size::new(
169 f32::max(acc.width, n.layout.x + n.layout.width),
170 f32::max(acc.height, n.layout.y + n.layout.height),
171 )
172 });
173
174 Scene {
175 nodes: scene_nodes,
176 gradient_defs,
177 blur_filters,
178 viewport,
179 content_size,
180 }
181}
182
183fn push_shadow_rect(
184 nodes: &mut Vec<SceneNode>,
185 blur_filters: &mut Vec<BlurFilterDef>,
186 filter_counter: &mut u32,
187 bounds: Rect,
188 shadow: BoxShadow,
189 border_radius: f32,
190 clip_id: Option<u32>,
191) {
192 let shadow_bounds = Rect::new(
193 bounds.x + shadow.offset_x - shadow.spread_radius,
194 bounds.y + shadow.offset_y - shadow.spread_radius,
195 bounds.width + shadow.spread_radius * 2.0,
196 bounds.height + shadow.spread_radius * 2.0,
197 );
198
199 let filter_id = if shadow.blur_radius > 0.0 {
201 let id = format!("blur{}", filter_counter);
202 *filter_counter += 1;
203 blur_filters.push(BlurFilterDef {
204 id: id.clone(),
205 std_deviation: shadow.blur_radius / 2.0, });
207 Some(id)
208 } else {
209 None
210 };
211
212 nodes.push(SceneNode::Rect(RectSceneNode {
213 bounds: shadow_bounds,
214 background: Background::Color(shadow.color),
215 gradient_id: None,
216 border_width: 0.0,
217 border_color: Color::TRANSPARENT,
218 border_radius: border_radius + shadow.spread_radius,
219 opacity: shadow.color.a as f32 / 255.0,
220 clip_id,
221 transform: Transform::default(),
222 backdrop_blur_radius: 0.0,
223 blur_radius: shadow.blur_radius,
224 filter_id,
225 }));
226}
227
228fn read_image_size(bytes: &[u8]) -> (f32, f32) {
230 use image::ImageReader;
231 use std::io::Cursor;
232 if let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() {
233 if let Ok((w, h)) = reader.into_dimensions() {
234 return (w as f32, h as f32);
235 }
236 }
237 (0.0, 0.0) }
239
240fn guess_mime(src: &str) -> String {
241 if src.ends_with(".png") {
242 "image/png".into()
243 } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
244 "image/jpeg".into()
245 } else if src.ends_with(".webp") {
246 "image/webp".into()
247 } else if src.ends_with(".gif") {
248 "image/gif".into()
249 } else {
250 "image/png".into()
251 }
252}
253
254fn placeholder_png() -> Vec<u8> {
255 vec![
257 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
258 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
259 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xd8,
260 0xd8, 0xd8, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
261 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
262 ]
263}