Files
ARCADIA-Studio/harness/experiments/scenario-label-wgpu-20260913/main.rs
T

351 lines
17 KiB
Rust

//! 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);
const POSITION: Point = Point::new(36.25, 40.5);
const PIXEL_DELTA: u8 = 3;
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<T>(future: impl Future<Output = T>) -> T {
struct WakeThread(std::thread::Thread);
impl std::task::Wake for WakeThread {
fn wake(self: Arc<Self>) { self.0.unpark(); }
fn wake_by_ref(self: &Arc<Self>) { 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<u32>,
dpi: f32,
zoom: f32,
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 advance = label.bounds_at(session, zoom, 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, 3., 2.);
let corridor = Rectangle { 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, 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<u8> {
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: 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, 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<String>) {
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<u32>) {
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 label = cache.resolve(session, case.source, width, 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;
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 (before, after) in decoration.chunks_exact(4).zip(composite.chunks_exact(4)) {
// 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));
}
}
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);
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);
let mut report = format!(
"source={:?}\ndisplayed={displayed:?}\nretained={retained:?}\nstyle={:?}\nzoom={zoom}\ndpi={dpi}\nwidth={width}\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}\ninvalid_checks={invalid}\ncandidate_findings={finding}\n",
case.source, case.style, surface.physical, surface.advance, surface.plate, surface.blockers,
);
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(<iced::Renderer as Headless>::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!("font_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); }
}