Skip to main content

falsegreen_ui_render/
lib.rs

1//! Deterministic supplemental capture.
2//!
3//! The software backend is intentionally small and deterministic. It is suitable for evidence
4//! crops and local diagnostics, not a claim of cross-GPU pixel equivalence.
5
6use falsegreen_ui_core::{PaintPrimitive, Rect, UiTree};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use thiserror::Error;
10
11pub const SOFTWARE_BACKEND_ID: &str = "falsegreen-ui-render/software-raster-v1";
12pub const QUALIFIED_FONT_FAMILY: &str = "DejaVu Sans";
13pub const QUALIFIED_FONT_ASSET: &str = "assets/fonts/DejaVuSans.ttf";
14pub const QUALIFIED_FONT_SHA256: &str =
15    "b4c632e3cdf9acc7f28758fb5a323c8524d7fc6660d46904d9b6cbe2809c419c";
16pub const QUALIFIED_FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/DejaVuSans.ttf");
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct FontIdentity {
20    pub family: String,
21    pub asset: String,
22    pub sha256: String,
23    pub shaping: String,
24}
25
26pub fn qualified_font_identity() -> FontIdentity {
27    FontIdentity {
28        family: QUALIFIED_FONT_FAMILY.into(),
29        asset: QUALIFIED_FONT_ASSET.into(),
30        sha256: QUALIFIED_FONT_SHA256.into(),
31        shaping: "semantic-text-only; glyph rasterization not authoritative".into(),
32    }
33}
34
35/// Validate the exact packaged font boundary before a renderer promotes font-dependent output.
36pub fn validate_qualified_font_asset() -> Result<FontIdentity, RenderError> {
37    validate_font_bytes(QUALIFIED_FONT_BYTES)
38}
39
40fn validate_font_bytes(bytes: &[u8]) -> Result<FontIdentity, RenderError> {
41    let identity = qualified_font_identity();
42    let actual = falsegreen_ui_core::sha256_hex(bytes);
43    if actual != identity.sha256 {
44        return Err(RenderError::FontDigestMismatch {
45            expected: identity.sha256,
46            actual,
47        });
48    }
49    Ok(identity)
50}
51
52/// Validate an explicitly supplied font against the same frozen identity.
53pub fn validate_font_asset(path: &Path) -> Result<FontIdentity, RenderError> {
54    let bytes = std::fs::read(path)?;
55    validate_font_bytes(&bytes)
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum PixelAuthority {
61    Supplemental,
62    Authoritative,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct RendererQualification {
67    pub backend: String,
68    pub runs: u32,
69    pub unique_rgba_digests: Vec<String>,
70    pub pixel_authority: PixelAuthority,
71    pub vello: Option<String>,
72    pub wgpu: Option<String>,
73    pub notes: Vec<String>,
74}
75
76/// Repeat the same capture to make determinism measurable and record the pixel-authority
77/// decision. This software backend remains supplemental because it is not a Vello/wgpu capture.
78pub fn run_repeatability_experiment(
79    tree: &UiTree,
80    runs: u32,
81) -> Result<RendererQualification, RenderError> {
82    let runs = runs.max(1);
83    let mut digests = Vec::with_capacity(runs as usize);
84    for _ in 0..runs {
85        digests.push(render(tree)?.rgba_sha256);
86    }
87    digests.sort();
88    digests.dedup();
89    Ok(RendererQualification {
90        backend: SOFTWARE_BACKEND_ID.into(),
91        runs,
92        unique_rgba_digests: digests,
93        pixel_authority: PixelAuthority::Supplemental,
94        vello: None,
95        wgpu: None,
96        notes: vec![
97            "Vello/wgpu production qualification is deferred; neither is linked or executed by UI V1".into(),
98            "PNG pixels are supplemental; normalized semantics and geometry remain authoritative"
99                .into(),
100        ],
101    })
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RenderIdentity {
106    pub backend: String,
107    pub pixel_format: String,
108    pub dpr_milli: u32,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct RenderedFrame {
113    pub width: u32,
114    pub height: u32,
115    pub rgba_sha256: String,
116    pub identity: RenderIdentity,
117    #[serde(skip)]
118    pub rgba: Vec<u8>,
119}
120
121impl RenderedFrame {
122    pub fn write_png(&self, path: &Path) -> Result<(), RenderError> {
123        if let Some(parent) = path.parent() {
124            std::fs::create_dir_all(parent).map_err(RenderError::Io)?;
125        }
126        let file = std::fs::File::create(path).map_err(RenderError::Io)?;
127        let writer = std::io::BufWriter::new(file);
128        let mut encoder = png::Encoder::new(writer, self.width, self.height);
129        encoder.set_color(png::ColorType::Rgba);
130        encoder.set_depth(png::BitDepth::Eight);
131        let mut stream = encoder
132            .write_header()
133            .map_err(|error| RenderError::Png(error.to_string()))?;
134        stream
135            .write_image_data(&self.rgba)
136            .map_err(|error| RenderError::Png(error.to_string()))?;
137        Ok(())
138    }
139}
140
141pub fn render(tree: &UiTree) -> Result<RenderedFrame, RenderError> {
142    tree.validate().map_err(RenderError::InvalidTree)?;
143    let width = tree.viewport.width;
144    let height = tree.viewport.height;
145    let mut rgba = vec![255_u8; width as usize * height as usize * 4];
146    let mut nodes = tree.nodes.iter().collect::<Vec<_>>();
147    nodes.sort_by_key(|node| {
148        (
149            node.z_index,
150            tree.nodes
151                .iter()
152                .position(|candidate| candidate.id == node.id)
153                .unwrap_or(0),
154        )
155    });
156    for node in nodes {
157        if !node.state.visible {
158            continue;
159        }
160        for primitive in &node.paint {
161            raster_primitive(&mut rgba, width, height, primitive, node.clip)?;
162        }
163        // A text node is represented in the scene, while the software fallback paints a
164        // stable text stripe. Semantic text remains authoritative in the normalized tree.
165        if node.paint.is_empty() && node.text.is_some() {
166            fill_rect(
167                &mut rgba,
168                width,
169                height,
170                node.bounds,
171                [55, 65, 81, 255],
172                node.clip,
173            );
174        }
175    }
176    let rgba_sha256 = falsegreen_ui_core::sha256_hex(&rgba);
177    Ok(RenderedFrame {
178        width,
179        height,
180        rgba_sha256,
181        identity: RenderIdentity {
182            backend: SOFTWARE_BACKEND_ID.into(),
183            pixel_format: "RGBA8-srgb".into(),
184            dpr_milli: tree.viewport.dpr_milli,
185        },
186        rgba,
187    })
188}
189
190fn raster_primitive(
191    pixels: &mut [u8],
192    width: u32,
193    height: u32,
194    primitive: &PaintPrimitive,
195    clip: Option<Rect>,
196) -> Result<(), RenderError> {
197    match primitive {
198        PaintPrimitive::Fill { rect, color, .. } => {
199            fill_rect(pixels, width, height, *rect, *color, clip)
200        }
201        PaintPrimitive::Stroke {
202            rect,
203            color,
204            width: stroke_width,
205            ..
206        } => {
207            fill_rect(
208                pixels,
209                width,
210                height,
211                Rect::new(rect.x, rect.y, rect.width, *stroke_width),
212                *color,
213                clip,
214            );
215            fill_rect(
216                pixels,
217                width,
218                height,
219                Rect::new(
220                    rect.x,
221                    rect.bottom() - *stroke_width,
222                    rect.width,
223                    *stroke_width,
224                ),
225                *color,
226                clip,
227            );
228            fill_rect(
229                pixels,
230                width,
231                height,
232                Rect::new(rect.x, rect.y, *stroke_width, rect.height),
233                *color,
234                clip,
235            );
236            fill_rect(
237                pixels,
238                width,
239                height,
240                Rect::new(
241                    rect.right() - *stroke_width,
242                    rect.y,
243                    *stroke_width,
244                    rect.height,
245                ),
246                *color,
247                clip,
248            );
249        }
250        PaintPrimitive::Text {
251            rect, color, text, ..
252        } => {
253            // The glyph-free fallback is deterministic and keeps a visual proof of text bounds.
254            let text_width = (text.chars().count() as f32 * 5.0).min(rect.width.max(0.0));
255            fill_rect(
256                pixels,
257                width,
258                height,
259                Rect::new(rect.x, rect.y, text_width, rect.height.min(3.0)),
260                *color,
261                clip,
262            );
263        }
264        PaintPrimitive::Asset { rect, asset } => {
265            let digest = asset.sha256.as_bytes();
266            let color = [
267                digest.first().copied().unwrap_or(0),
268                digest.get(1).copied().unwrap_or(0),
269                digest.get(2).copied().unwrap_or(0),
270                255,
271            ];
272            fill_rect(pixels, width, height, *rect, color, clip);
273        }
274    }
275    Ok(())
276}
277
278fn fill_rect(
279    pixels: &mut [u8],
280    width: u32,
281    height: u32,
282    rect: Rect,
283    color: [u8; 4],
284    clip: Option<Rect>,
285) {
286    let clipped = clip
287        .and_then(|clip| rect.intersection(clip))
288        .unwrap_or(rect);
289    let x0 = clipped.x.floor().max(0.0) as u32;
290    let y0 = clipped.y.floor().max(0.0) as u32;
291    let x1 = clipped.right().ceil().min(width as f32).max(0.0) as u32;
292    let y1 = clipped.bottom().ceil().min(height as f32).max(0.0) as u32;
293    for y in y0.min(height)..y1.min(height) {
294        for x in x0.min(width)..x1.min(width) {
295            let index = ((y * width + x) * 4) as usize;
296            if color[3] == 255 {
297                pixels[index..index + 4].copy_from_slice(&color);
298            } else if color[3] != 0 {
299                let alpha = color[3] as u16;
300                let inverse = 255_u16 - alpha;
301                for channel in 0..3 {
302                    pixels[index + channel] = ((color[channel] as u16 * alpha
303                        + pixels[index + channel] as u16 * inverse)
304                        / 255) as u8;
305                }
306                pixels[index + 3] =
307                    (alpha + pixels[index + 3] as u16 * inverse / 255).min(255) as u8;
308            }
309        }
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct FrameDiff {
315    pub differing_pixels: u64,
316    pub total_pixels: u64,
317    pub max_channel_delta: u8,
318    pub tolerance: u8,
319}
320
321impl FrameDiff {
322    pub fn passes(&self) -> bool {
323        self.differing_pixels == 0
324    }
325}
326
327pub fn diff(
328    left: &RenderedFrame,
329    right: &RenderedFrame,
330    tolerance: u8,
331) -> Result<FrameDiff, RenderError> {
332    if left.width != right.width || left.height != right.height {
333        return Err(RenderError::SizeMismatch);
334    }
335    let mut differing_pixels = 0;
336    let mut max_channel_delta = 0;
337    for channels in left.rgba.chunks_exact(4).zip(right.rgba.chunks_exact(4)) {
338        let delta = channels
339            .0
340            .iter()
341            .zip(channels.1.iter())
342            .map(|(a, b)| a.abs_diff(*b))
343            .max()
344            .unwrap_or(0);
345        max_channel_delta = max_channel_delta.max(delta);
346        if delta > tolerance {
347            differing_pixels += 1;
348        }
349    }
350    Ok(FrameDiff {
351        differing_pixels,
352        total_pixels: (left.width * left.height) as u64,
353        max_channel_delta,
354        tolerance,
355    })
356}
357
358#[derive(Debug, Error)]
359pub enum RenderError {
360    #[error("normalized tree is invalid: {0}")]
361    InvalidTree(falsegreen_ui_core::ValidationError),
362    #[error("frame sizes do not match")]
363    SizeMismatch,
364    #[error("PNG error: {0}")]
365    Png(String),
366    #[error("I/O error: {0}")]
367    Io(#[from] std::io::Error),
368    #[error("font digest mismatch: expected {expected}, got {actual}")]
369    FontDigestMismatch { expected: String, actual: String },
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use falsegreen_ui_core::{Role, UiNode, Viewport};
376
377    fn tree() -> UiTree {
378        let root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 16.0, 16.0)).paint(
379            PaintPrimitive::Fill {
380                rect: Rect::new(0.0, 0.0, 16.0, 16.0),
381                color: [10, 20, 30, 255],
382                radius: 0.0,
383            },
384        );
385        UiTree::new(Viewport::new(16, 16), "root", vec![root])
386    }
387
388    #[test]
389    fn software_capture_is_repeatable() {
390        let a = render(&tree()).unwrap();
391        let b = render(&tree()).unwrap();
392        assert_eq!(a.rgba_sha256, b.rgba_sha256);
393        assert!(diff(&a, &b, 0).unwrap().passes());
394    }
395
396    #[test]
397    fn repeatability_experiment_records_supplemental_authority() {
398        let qualification = run_repeatability_experiment(&tree(), 3).unwrap();
399        assert_eq!(qualification.runs, 3);
400        assert_eq!(qualification.unique_rgba_digests.len(), 1);
401        assert_eq!(qualification.pixel_authority, PixelAuthority::Supplemental);
402        assert_eq!(qualified_font_identity().sha256, QUALIFIED_FONT_SHA256);
403    }
404
405    #[test]
406    fn missing_or_changed_font_cannot_be_silent() {
407        let missing = validate_font_asset(Path::new("work/missing-font.ttf"));
408        assert!(matches!(missing, Err(RenderError::Io(_))));
409
410        let identity = validate_qualified_font_asset().unwrap();
411        assert_eq!(
412            falsegreen_ui_core::sha256_hex(QUALIFIED_FONT_BYTES),
413            QUALIFIED_FONT_SHA256
414        );
415        assert_eq!(identity.sha256, QUALIFIED_FONT_SHA256);
416
417        let changed =
418            std::env::temp_dir().join(format!("falsegreen-ui-changed-font-{}", std::process::id()));
419        std::fs::write(&changed, b"changed-font").unwrap();
420        assert!(matches!(
421            validate_font_asset(&changed),
422            Err(RenderError::FontDigestMismatch { .. })
423        ));
424        std::fs::remove_file(changed).unwrap();
425    }
426}