//! Standalone offscreen evaluator; deliberately compiled WITHOUT --test. //! Imports the actual candidate, never a copy of its shaping or truncation code. #[path = "../../../crates/oa-desktop/src/behavior/scenario_label.rs"] mod scenario_label; use iced::advanced::{ graphics::{geometry::Renderer as _, text as graphics_text}, renderer::{Headless, Renderer as _}, text::{Alignment, LineHeight, Shaping}, }; use iced::{Color, Font, Pixels, Point, Rectangle, Size, Vector, widget::canvas}; use scenario_label::{Cache, Label, RenderSession, Style}; use std::{collections::BTreeSet, fmt::Write as _, fs, future::Future, path::Path, sync::Arc}; const PAN: Vector = Vector::new(37.25, 23.5); // POSITION remains the TRUE corridor origin, unchanged from empirical policy 1. const POSITION: Point = Point::new(36.25, 40.5); const PIXEL_DELTA: u8 = 3; const HORIZONTAL_INSET: f32 = 3.; const POLICY: &str = "2-fixed-corridor-inset-3-world-units"; struct Case { name: &'static str, source: &'static str, style: Style, require_italic: bool, } fn cases() -> [Case; 4] { let advanced = Style { shaping: Shaping::Advanced, ..Style::default() }; [ Case { name: "realistic-default", source: "1. call · Request valve repair · Technician service conversation", style: Style::default(), require_italic: false, }, Case { name: "cjk-advanced", source: "2. call · 请求阀门维修并确认压力传感器状态 · 技术人员服务会话", style: advanced, require_italic: false, }, Case { name: "combining-advanced", source: "3. call · A\u{0301}\u{0323}larm e\u{0301}valuation · verify technician's pressure-report acknowledgement", style: advanced, require_italic: false, }, Case { name: "italic-advanced", source: "jfj · Verify valve qualification, pressure relief and technician signoff", style: Style { font: Font { style: iced::font::Style::Italic, ..Font::DEFAULT }, ..advanced }, require_italic: true, }, ] } // No runtime or test-suite global executor: this executable owns the GPU/font // process. WGPU's adapter/device futures provide their normal wake notifications. fn block_on(future: impl Future) -> T { struct WakeThread(std::thread::Thread); impl std::task::Wake for WakeThread { fn wake(self: Arc) { self.0.unpark(); } fn wake_by_ref(self: &Arc) { self.0.unpark(); } } let waker = std::task::Waker::from(Arc::new(WakeThread(std::thread::current()))); let mut context = std::task::Context::from_waker(&waker); let mut future = std::pin::pin!(future); loop { match future.as_mut().poll(&mut context) { std::task::Poll::Ready(value) => return value, std::task::Poll::Pending => std::thread::park(), } } } #[derive(Clone, Copy)] enum Pass { Text, ManualText, Decoration, Composite, } struct Surface { logical: Size, physical: Size, dpi: f32, zoom: f32, text_position: Point, advance: Rectangle, plate: Rectangle, corridor: Rectangle, blockers: [Rectangle; 2], } impl Surface { fn new(label: &Label, session: &RenderSession, zoom: f32, dpi: f32, width: f32) -> Self { let text_position = Point::new(POSITION.x + HORIZONTAL_INSET, POSITION.y); let advance = label.bounds_at(session, zoom, text_position).unwrap(); // Explicit EXPERIMENT candidate: 3 horizontal / 2 vertical world units. // It is not an accepted production padding or an assertion about ink. let plate = grow(advance, HORIZONTAL_INSET, 2.); // Do not derive corridor.x from the shifted text or widen the corridor // to excuse overhang. Its original origin/width and blockers stay fixed. let corridor = Rectangle { x: POSITION.x, width, ..advance }; let blockers = [ Rectangle { x: POSITION.x - 12., y: POSITION.y - 12., width: 8., height: 52., }, Rectangle { x: POSITION.x + width + 4., y: POSITION.y - 12., width: 8., height: 52., }, ]; let physical = Size::new( ((PAN.x + (POSITION.x + width + 70.) * zoom) * dpi).ceil() as u32, ((PAN.y + (POSITION.y + 90.) * zoom) * dpi).ceil() as u32, ); let logical = Size::new(physical.width as f32 / dpi, physical.height as f32 / dpi); Self { logical, physical, dpi, zoom, text_position, advance, plate, corridor, blockers, } } fn pixels(&self, rect: Rectangle) -> Rectangle { Rectangle { x: (PAN.x + rect.x * self.zoom) * self.dpi, y: (PAN.y + rect.y * self.zoom) * self.dpi, width: rect.width * self.zoom * self.dpi, height: rect.height * self.zoom * self.dpi, } } } fn grow(rect: Rectangle, x: f32, y: f32) -> Rectangle { Rectangle { x: rect.x - x, y: rect.y - y, width: rect.width + 2. * x, height: rect.height + 2. * y, } } fn render( renderer: &mut iced::Renderer, surface: &Surface, label: &Label, session: &RenderSession, style: Style, pass: Pass, pixel_shift: f32, ) -> Vec { renderer.reset(Rectangle::with_size(surface.logical)); let mut frame = canvas::Frame::new(renderer, surface.logical); // Same order as actual scenario Frame: camera translation, then uniform scale. // The extra translation is only the independent integer-pixel shift oracle. frame.translate(Vector::new(PAN.x + pixel_shift / surface.dpi, PAN.y)); frame.scale(surface.zoom); if matches!(pass, Pass::Decoration | Pass::Composite) { frame.fill_rectangle( surface.plate.position(), surface.plate.size(), Color::from_rgb8(255, 230, 120), ); // Backplate precedes activation-like blockers; text remains foreground. // These are probes, not imported Scene activations or a corridor planner. for blocker in surface.blockers { frame.fill_rectangle( blocker.position(), blocker.size(), Color::from_rgb8(0, 40, 255), ); } } if !matches!(pass, Pass::Decoration) { let text = if matches!(pass, Pass::ManualText) { // Independently construct the documented paint payload. Both paths // use actual Frame::fill_text; no alternate renderer is involved. canvas::Text { content: label.displayed(session, surface.zoom).unwrap().to_owned(), position: surface.text_position, color: Color::BLACK, size: style.size, line_height: style.line_height, font: style.font, shaping: style.shaping, max_width: f32::INFINITY, ..canvas::Text::default() } } else { label .canvas_text(session, surface.zoom, surface.text_position, Color::BLACK) .unwrap() }; frame.fill_text(text); } renderer.draw_geometry(frame.into_geometry()); let pixels = Headless::screenshot(renderer, surface.physical, surface.dpi, Color::WHITE); assert_eq!( pixels.len(), surface.physical.width as usize * surface.physical.height as usize * 4 ); pixels } fn differs(a: &[u8], b: &[u8]) -> bool { a.iter().zip(b).any(|(&a, &b)| a.abs_diff(b) > PIXEL_DELTA) } fn inside(rect: Rectangle, x: usize, y: usize) -> bool { let x = x as f32 + 0.5; let y = y as f32 + 0.5; x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height } #[derive(Default, Debug)] struct Ink { count: usize, bounds: Option<(usize, usize, usize, usize)>, outside_advance: usize, outside_advance_plus_one_pixel: usize, outside_plate: usize, outside_plate_plus_one_pixel: usize, outside_corridor_x_plus_one_pixel: usize, touches_viewport: usize, } fn ink(pixels: &[u8], surface: &Surface) -> Ink { let advance = surface.pixels(surface.advance); let plate = surface.pixels(surface.plate); let corridor = surface.pixels(surface.corridor); let mut out = Ink::default(); for (i, rgba) in pixels.chunks_exact(4).enumerate() { if !differs(rgba, &[255, 255, 255, 255]) { continue; } let (x, y) = ( i % surface.physical.width as usize, i / surface.physical.width as usize, ); out.count += 1; out.bounds = Some(match out.bounds { None => (x, y, x + 1, y + 1), Some((l, t, r, b)) => (l.min(x), t.min(y), r.max(x + 1), b.max(y + 1)), }); out.outside_advance += usize::from(!inside(advance, x, y)); out.outside_advance_plus_one_pixel += usize::from(!inside(grow(advance, 1., 1.), x, y)); out.outside_plate += usize::from(!inside(plate, x, y)); out.outside_plate_plus_one_pixel += usize::from(!inside(grow(plate, 1., 1.), x, y)); let px = x as f32 + 0.5; out.outside_corridor_x_plus_one_pixel += usize::from(px < corridor.x - 1. || px >= corridor.x + corridor.width + 1.); out.touches_viewport += usize::from( x == 0 || y == 0 || x + 1 == surface.physical.width as usize || y + 1 == surface.physical.height as usize, ); } out } // Independent inspection of the actual shaped glyphs is ONLY a coverage gate: // metrics from this buffer do not serve as the painted-ink oracle. fn font_coverage( displayed: &str, style: Style, zoom: f32, ) -> (usize, usize, bool, BTreeSet) { let size = Pixels(style.size.0 * zoom); let line_height = match style.line_height { LineHeight::Absolute(value) => value.0 * zoom, LineHeight::Relative(value) => value * size.0, }; let mut fonts = graphics_text::font_system().write().unwrap(); let mut shapes = graphics_text::Cache::new(); let (_, entry) = shapes.allocate( fonts.raw(), graphics_text::cache::Key { content: displayed, size: size.0, line_height, font: style.font, bounds: Size::INFINITE, shaping: style.shaping, align_x: Alignment::Default, }, ); let (mut glyphs, mut missing, mut italic) = (0, 0, false); let mut faces = BTreeSet::new(); for run in entry.buffer.layout_runs() { for glyph in run.glyphs { glyphs += 1; missing += usize::from(glyph.glyph_id == 0); if let Some(face) = fonts.raw().db().face(glyph.font_id) { italic |= face.style != graphics_text::cosmic_text::fontdb::Style::Normal; faces.insert(format!( "{:?} {:?} {:?}", glyph.font_id, face.families, face.style )); } } } (glyphs, missing, italic, faces) } fn save_pixels(dir: &Path, name: &str, pixels: &[u8], size: Size) { fs::write(dir.join(format!("{name}.rgba")), pixels).unwrap(); // Standard portable pixmap for immediate image inspection, no image crate. let mut ppm = format!("P6\n{} {}\n255\n", size.width, size.height).into_bytes(); for pixel in pixels.chunks_exact(4) { ppm.extend_from_slice(&pixel[..3]); } fs::write(dir.join(format!("{name}.ppm")), ppm).unwrap(); } fn evaluate( renderer: &mut iced::Renderer, session: &RenderSession, case: &Case, zoom: f32, dpi: f32, width: f32, dir: &Path, ) -> (usize, usize, bool) { fs::create_dir(dir).unwrap(); let mut cache = Cache::default(); let text_budget = width - 2. * HORIZONTAL_INSET; assert!( text_budget > 0., "these 32 probes require a positive inset text budget" ); let label = cache .resolve(session, case.source, text_budget, zoom, case.style) .unwrap(); let displayed = label.displayed(session, zoom).unwrap(); let retained = label.retained_source(session, zoom).unwrap(); let surface = Surface::new(&label, session, zoom, dpi, width); let text = render( renderer, &surface, &label, session, case.style, Pass::Text, 0., ); let manual = render( renderer, &surface, &label, session, case.style, Pass::ManualText, 0., ); let moved = render( renderer, &surface, &label, session, case.style, Pass::Text, 16., ); let decoration = render( renderer, &surface, &label, session, case.style, Pass::Decoration, 0., ); let composite = render( renderer, &surface, &label, session, case.style, Pass::Composite, 0., ); let measured_ink = ink(&text, &surface); let (glyphs, missing, italic, faces) = font_coverage(displayed, case.style, zoom); let payload_mismatch = text .chunks_exact(4) .zip(manual.chunks_exact(4)) .filter(|(a, b)| differs(a, b)) .count(); let mut translation_mismatch = 0; let mut blocker_changed = 0; let mut blocker_samples = 0; let plate_inside_corridor_x = surface.plate.x >= surface.corridor.x && surface.plate.x + surface.plate.width <= surface.corridor.x + surface.corridor.width; let corridor_pixels = surface.pixels(surface.corridor); let mut painted_plate_samples = 0; let mut painted_plate_outside_corridor_x = 0; let mut painted_plate_outside_corridor_x_plus_one_pixel = 0; for (i, rgba) in moved.chunks_exact(4).enumerate() { let x = i % surface.physical.width as usize; let expected = if x >= 16 { &text[(i - 16) * 4..(i - 16) * 4 + 4] } else { &[255, 255, 255, 255] }; translation_mismatch += usize::from(differs(rgba, expected)); } for (i, (before, after)) in decoration .chunks_exact(4) .zip(composite.chunks_exact(4)) .enumerate() { // Sample actual opaque blue blocker interiors, not a guessed screen box. if before[0] < 8 && before[1] < 60 && before[2] > 245 { blocker_samples += 1; blocker_changed += usize::from(differs(before, after)); } // Yellow plate over white has full red and attenuated blue. This mask // also includes weak antialiased plate edges; blue blockers cannot match. if before[0] > 247 && before[2] < 252 { painted_plate_samples += 1; let x = (i % surface.physical.width as usize) as f32 + 0.5; painted_plate_outside_corridor_x += usize::from( x < corridor_pixels.x || x >= corridor_pixels.x + corridor_pixels.width, ); painted_plate_outside_corridor_x_plus_one_pixel += usize::from( x < corridor_pixels.x - 1. || x >= corridor_pixels.x + corridor_pixels.width + 1., ); } } let source_expectation = if width > 500. { displayed == case.source && retained == (0..case.source.len()) } else { displayed.ends_with('…') && retained.end < case.source.len() && retained.end > 0 }; // Infrastructure/contract failures differ from geometric candidate findings. let invalid = usize::from(!source_expectation) + usize::from(glyphs == 0 || missing > 0 || (case.require_italic && !italic)) + usize::from(measured_ink.count == 0 || measured_ink.touches_viewport > 0) + usize::from( payload_mismatch > 0 || translation_mismatch > 0 || blocker_samples == 0 || painted_plate_samples == 0, ); let finding = usize::from(measured_ink.outside_plate_plus_one_pixel > 0) + usize::from(measured_ink.outside_corridor_x_plus_one_pixel > 0) + usize::from(blocker_changed > 0) + usize::from(!plate_inside_corridor_x) + usize::from(painted_plate_outside_corridor_x_plus_one_pixel > 0); let mut report = format!( "policy={POLICY}\nsource={:?}\ndisplayed={displayed:?}\nretained={retained:?}\nstyle={:?}\nzoom={zoom}\ndpi={dpi}\nwidth={width}\ntext_budget={text_budget}\nhorizontal_inset={HORIZONTAL_INSET}\nphysical={:?}\nadvance_world={:?}\nplate_world={:?}\nblockers_world={:?}\nink={measured_ink:#?}\nglyphs={glyphs}\nmissing_glyphs={missing}\nresolved_italic={italic}\npayload_mismatch_pixels={payload_mismatch}\ntranslation_mismatch_pixels={translation_mismatch}\nblocker_samples={blocker_samples}\nblocker_changed_pixels={blocker_changed}\nplate_inside_corridor_x={plate_inside_corridor_x}\npainted_plate_samples={painted_plate_samples}\npainted_plate_outside_corridor_x={painted_plate_outside_corridor_x}\npainted_plate_outside_corridor_x_plus_one_pixel={painted_plate_outside_corridor_x_plus_one_pixel}\ninvalid_checks={invalid}\ncandidate_findings={finding}\n", case.source, case.style, surface.physical, surface.advance, surface.plate, surface.blockers, ); writeln!( &mut report, "corridor_world={:?}\ntext_position={:?}", surface.corridor, surface.text_position ) .unwrap(); for face in faces { writeln!(&mut report, "resolved_face={face}").unwrap(); } fs::write(dir.join("metrics.txt"), report).unwrap(); for (name, pixels) in [ ("text", &text), ("decoration", &decoration), ("composite", &composite), ] { save_pixels(dir, name, pixels, surface.physical); } if payload_mismatch > 0 { save_pixels(dir, "manual-control", &manual, surface.physical); } if translation_mismatch > 0 { save_pixels(dir, "shift-control", &moved, surface.physical); } (invalid, finding, measured_ink.outside_advance > 0) } fn main() { let args: Vec<_> = std::env::args_os().collect(); assert_eq!( args.len(), 2, "usage: scenario-label-wgpu.exe NEW_OUTPUT_DIRECTORY" ); let out = Path::new(&args[1]); fs::create_dir(out).expect("output directory must be new; preserve prior runs"); // Initialize the system font database before the new renderer. No fonts are // loaded after this point, through all screenshots and coverage inspection. let version = graphics_text::font_system().read().unwrap().version(); let mut renderer = block_on(::new( Font::DEFAULT, Pixels(16.), Some("wgpu"), )) .expect("WGPU adapter/device unavailable: infrastructure failure, never a skipped pass"); assert_eq!(Headless::name(&renderer), "wgpu"); let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap(); let mut summary = format!( "policy={POLICY}\nhorizontal_inset={HORIZONTAL_INSET}\nfont_version={version:?}\nrenderer=wgpu\nchannel_delta_threshold={PIXEL_DELTA}\nimported_module=crates/oa-desktop/src/behavior/scenario_label.rs\n" ); let (mut cases_run, mut invalid, mut findings, mut advance_spill_cases) = (0, 0, 0, 0); for case in cases() { for (zoom, dpi) in [(0.65, 1.), (1., 1.), (1.75, 1.), (1., 1.5)] { for width in [160., 760.] { let name = format!("{}-z{zoom}-dpi{dpi}-w{width}", case.name); let dir = out.join(&name); let (bad, found, advance_spill) = evaluate(&mut renderer, &session, &case, zoom, dpi, width, &dir); // Summary is only an index; full numeric ink fields are per case. advance_spill_cases += usize::from(advance_spill); writeln!(&mut summary, "{name}: invalid={bad} findings={found}").unwrap(); invalid += bad; findings += found; cases_run += 1; } } } assert_eq!(cases_run, 32); writeln!(&mut summary, "cases={cases_run}\ninvalid_checks={invalid}\ncandidate_findings={findings}\nadvance_spill_cases={advance_spill_cases}\n").unwrap(); summary.push_str("Advance spill is measured evidence, not automatically a failure. Candidate findings reject this probe's padding/corridor assumptions. This is not Scene/native acceptance.\n"); fs::write(out.join("summary.txt"), &summary).unwrap(); print!("{summary}"); if invalid > 0 { std::process::exit(3); } if findings > 0 { std::process::exit(2); } }