diff --git a/spikes/editor-toolkit/Cargo.lock b/spikes/editor-toolkit/Cargo.lock index c13c4c5..24fe61c 100644 --- a/spikes/editor-toolkit/Cargo.lock +++ b/spikes/editor-toolkit/Cargo.lock @@ -638,6 +638,34 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "c1-egui-lyon" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytemuck", + "egui", + "egui-wgpu", + "epiphany-layout-ir", + "lyon", + "lyon_path", + "lyon_tessellation", + "pollster", + "round1-harness", +] + +[[package]] +name = "c2-vello" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytemuck", + "epiphany-layout-ir", + "pollster", + "round1-harness", + "vello", +] + [[package]] name = "calloop" version = "0.13.0" @@ -3605,6 +3633,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "round1-harness" +version = "0.1.0" +dependencies = [ + "epiphany-glyphs", + "epiphany-layout-ir", + "serde", + "serde_json", +] + [[package]] name = "round1-oracle" version = "0.1.0" diff --git a/spikes/editor-toolkit/Cargo.toml b/spikes/editor-toolkit/Cargo.toml index 92248a1..7d06bfd 100644 --- a/spikes/editor-toolkit/Cargo.toml +++ b/spikes/editor-toolkit/Cargo.toml @@ -5,6 +5,9 @@ members = [ "probe-vello", "probe-iced", "round1-oracle", + "round1-candidates/harness", + "round1-candidates/c1-egui-lyon", + "round1-candidates/c2-vello", ] # a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml new file mode 100644 index 0000000..f3866e2 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "c1-egui-lyon" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Round 1 candidate C1: egui's own paint pipeline (egui_wgpu::Renderer), +# fed an egui::epaint::Mesh tessellated by lyon from the typed PathCommand +# outline. Matches/exceeds probe-egui's Round-0 version pins (egui/eframe +# 0.35); egui_wgpu is added here specifically because Round 1 renders +# through it (Round 0 did not need to render anything). + +# wgpu itself is consumed via egui_wgpu's own re-export (`egui_wgpu::wgpu`) +# rather than as a separate top-level dependency, to guarantee the Device +# passed into `egui_wgpu::Renderer::new` is the exact type it expects. + +[dependencies] +round1-harness = { path = "../harness" } +epiphany-layout-ir = { path = "../../../../crates/epiphany-layout-ir" } +egui = "0.35" +egui-wgpu = "0.35" +lyon = "1.0" +lyon_path = "1.0" +lyon_tessellation = "1.0" +pollster = "0.4" +bytemuck = "1" +anyhow = "1" diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/main.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/main.rs new file mode 100644 index 0000000..5a79eda --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/main.rs @@ -0,0 +1,487 @@ +//! Round 1 candidate **C1 — egui + lyon**. +//! +//! Tessellates each Round-1 glyph's typed `PathCommand` outline with `lyon` +//! into an `egui::epaint::Mesh`, then draws it through **egui's own paint +//! pipeline** (`egui_wgpu::Renderer`) to an offscreen texture. The RGBA +//! readback goes to the candidate-neutral harness for classification against +//! the **frozen** `round1-oracle/oracle.json`. +//! +//! **Why it must go through `egui_wgpu::Renderer` and not a hand-rolled wgpu +//! pipeline.** Ruling A names the candidate as "lyon-tessellated meshes inside +//! egui". Rendering the lyon mesh through a bare pipeline would test lyon +//! alone and answer a different question — the same failure shape as Round 0's +//! iced side channel, which read back cleanly while proving nothing about its +//! subject. So this binary builds a real `epaint::ClippedPrimitive` and calls +//! `Renderer::update_buffers` + `Renderer::render`. +//! +//! Windowless: egui's renderer needs a `Device`/`Queue` and a target view, not +//! a surface, so no `eframe`/`winit` appears here. +//! +//! **The whole outline is tessellated as ONE compound path.** `lyon`'s +//! `FillTessellator` applies the fill rule across every contour in the path, +//! so bounded counters survive as holes and disjoint components are all +//! painted. Tessellating each subpath separately would fill counters solid and +//! silently pass the test that exists to catch exactly that. + +use anyhow::{anyhow, Result}; +use egui::epaint::{ClippedPrimitive, Mesh, Primitive, Vertex}; +use egui::{Color32, Pos2, Rect, TextureId}; +use egui_wgpu::wgpu; +use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor}; +use epiphany_layout_ir::PathCommand; +use lyon_path::math::point as lyon_point; +use lyon_path::Path as LyonPath; +use lyon_tessellation::{ + BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers, +}; +use round1_harness as harness; + +const INK: Color32 = Color32::BLACK; +const GROUND: wgpu::Color = wgpu::Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, +}; + +/// Rec. 601 luma threshold used by the harness — stated, not implied. +const LUMA_THRESHOLD: u8 = 128; + +/// **Nominally 8x — the highest sample count BOTH candidates name**, which is +/// what pin 4 asks for ("identical MSAA sample count... 4x, or the highest all +/// survivors support"). An earlier revision ran C1 at 4x against C2's 8x, +/// which is not a common configuration at all: vello's `AaConfig` offers only +/// Area/Msaa8/Msaa16, so 8x is the highest both can name, and both adapters +/// advertise it. +/// +/// **This 8x is hardware multisampling and C2's is not.** Here it is a real +/// `sample_count: 8` colour attachment resolved by the GPU — which is exactly +/// why this binary must request `TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES`, +/// since 8x on `Rgba8Unorm` is outside the WebGPU baseline. vello's `Msaa8` is +/// its own compute-shader antialiasing into a single-sample storage texture, +/// with no multisample attachment at all. The declared sample count is +/// identical, as pin 4 requires; the work behind it is not, and Round 4's +/// timings must say so rather than let "8 == 8" stand in for parity. +const MSAA: u32 = 8; + +/// Printed beside `msaa_samples` in the run report, so the record itself +/// states the mechanism rather than leaving the bare integer to imply parity. +const AA_MECHANISM: &str = "hardware MSAA render-target attachment, GPU-resolved"; + +/// `Rgba8Unorm`, not sRGB, so both candidates share one format — vello's +/// `render_to_texture` requires it. A literal deviation from pin 4, immaterial +/// to fill correctness because only pure black and white are drawn. +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Builds one `lyon` path carrying **every** contour of the outline, already +/// in device coordinates. +/// +/// Staff-space is y-up, device space y-down, so the flip happens here rather +/// than in a transform — keeping this identical to the oracle's own +/// `device = (staff.x * scale + tx, ty - staff.y * scale)` rule. +fn build_path(outline: &[PathCommand], t: &harness::Transform) -> LyonPath { + let map = |p: epiphany_layout_ir::Point| { + lyon_point( + (p.x.0 as f64 * t.scale + t.tx) as f32, + (t.ty - p.y.0 as f64 * t.scale) as f32, + ) + }; + let mut builder = LyonPath::builder(); + let mut open = false; + for cmd in outline { + match cmd { + PathCommand::MoveTo(p) => { + if open { + builder.end(true); + } + builder.begin(map(*p)); + open = true; + } + PathCommand::LineTo(p) => { + builder.line_to(map(*p)); + } + PathCommand::CurveTo { + control1, + control2, + to, + } => { + builder.cubic_bezier_to(map(*control1), map(*control2), map(*to)); + } + PathCommand::Close => { + if open { + builder.end(true); + open = false; + } + } + } + } + if open { + builder.end(true); + } + builder.build() +} + +/// Tessellates the compound path into an `epaint::Mesh` bound to `tex`. +/// +/// **`tex` must be a texture actually registered with the renderer.** egui's +/// draw loop does `if let Some(..) = self.textures.get(&mesh.texture_id)` +/// (`egui-wgpu-0.35.0/src/renderer.rs:542`) and **silently skips** the +/// primitive when the id is unknown — no error, no warning, just an unpainted +/// mesh. Using `TextureId::default()` (the font atlas) without uploading one +/// therefore renders a blank target that reads as "every ink sample is +/// background", which is a probe defect wearing a candidate failure's costume. +/// This binary registers its own 1x1 opaque-white texture instead, so the +/// vertex colour passes through unmodulated at any UV. +fn tessellate(path: &LyonPath, tex: TextureId) -> Result { + let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); + let mut tess = FillTessellator::new(); + tess.tessellate_path( + path, + // NonZero: Bravura's contours are correctly oppositely wound, so + // even-odd and nonzero agree on every bundled counter (the oracle + // records that measurement). The rule is not what is under test — + // preserving contours and counters is. + &FillOptions::default().with_fill_rule(FillRule::NonZero), + &mut BuffersBuilder::new(&mut buffers, |v: FillVertex| { + let p = v.position(); + [p.x, p.y] + }), + ) + .map_err(|e| anyhow!("lyon tessellation failed: {e:?}"))?; + + let mut mesh = Mesh::with_texture(tex); + mesh.vertices = buffers + .vertices + .iter() + .map(|[x, y]| Vertex { + pos: Pos2::new(*x, *y), + uv: Pos2::ZERO, + color: INK, + }) + .collect(); + mesh.indices = buffers.indices; + Ok(mesh) +} + +fn readback( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Result> { + let unpadded = width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("c1-readback"), + size: (padded as u64) * (height as u64), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("c1-copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + + let slice = buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + device.poll(wgpu::PollType::wait_indefinitely())?; + rx.recv() + .map_err(|e| anyhow!("readback channel closed: {e}"))? + .map_err(|e| anyhow!("buffer map failed: {e}"))?; + + let mapped = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded as usize) * (height as usize)); + for row in 0..height as usize { + let start = row * padded as usize; + out.extend_from_slice(&mapped[start..start + unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + Ok(out) +} + +fn adapter_info(a: &wgpu::Adapter) -> harness::AdapterInfo { + let info = a.get_info(); + harness::AdapterInfo { + name: info.name.clone(), + backend: format!("{:?}", info.backend), + device_type: format!("{:?}", info.device_type), + vendor_id: info.vendor, + device_id: info.device, + } +} + +fn run_on(adapter: &wgpu::Adapter, oracle: &harness::OracleFile) -> Result { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("c1-egui-lyon"), + // 8x MSAA on Rgba8Unorm is not a WebGPU baseline guarantee — the spec + // guarantees only [1, 4] for this format, and wgpu rejects the + // pipeline without this feature. Both target adapters report + // [1, 2, 4, 8] with it enabled, so requesting it is what lets C1 meet + // pin 4's common-sample-count requirement rather than silently + // dropping to 4x. + required_features: wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES, + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }))?; + + let mut renderer = Renderer::new( + &device, + FORMAT, + RendererOptions { + msaa_samples: MSAA, + depth_stencil_format: None, + ..Default::default() + }, + ); + + // A 1x1 opaque-white texture, registered so the mesh's texture id resolves + // (see `tessellate`'s doc for why an unregistered id silently paints + // nothing). White modulates the vertex colour by 1.0, so the ink colour is + // unchanged. + let white = device.create_texture(&wgpu::TextureDescriptor { + label: Some("c1-white-1x1"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &white, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[255u8, 255, 255, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let white_view = white.create_view(&wgpu::TextureViewDescriptor::default()); + let white_id = + renderer.register_native_texture(&device, &white_view, wgpu::FilterMode::Nearest); + + let mut glyphs = Vec::new(); + for g in &oracle.glyphs { + let width = g.transform.target_width as u32; + let height = g.transform.target_height as u32; + + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + // The MSAA colour attachment, resolved into `resolve` below. + let msaa_tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("c1-msaa"), + size, + mip_level_count: 1, + sample_count: MSAA, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let resolve = device.create_texture(&wgpu::TextureDescriptor { + label: Some("c1-resolve"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let resolve_view = resolve.create_view(&wgpu::TextureViewDescriptor::default()); + + let outline = harness::outline_for(&g.name); + let path = build_path(&outline, &g.transform); + let mesh = tessellate(&path, white_id)?; + + let jobs = vec![ClippedPrimitive { + clip_rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(width as f32, height as f32)), + primitive: Primitive::Mesh(mesh), + }]; + let screen = ScreenDescriptor { + size_in_pixels: [width, height], + pixels_per_point: 1.0, + }; + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("c1-encode"), + }); + let extra = renderer.update_buffers(&device, &queue, &mut encoder, &jobs, &screen); + + { + let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("c1-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &msaa_view, + resolve_target: Some(&resolve_view), + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(GROUND), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + // `Renderer::render` requires a 'static pass. + let mut pass = pass.forget_lifetime(); + renderer.render(&mut pass, &jobs, &screen); + } + + queue.submit(extra.into_iter().chain([encoder.finish()])); + + let rgba = readback(&device, &queue, &resolve, width, height)?; + glyphs.push( + harness::evaluate_glyph(g, width, height, &rgba, LUMA_THRESHOLD) + .map_err(|e| anyhow!("{e}"))?, + ); + } + + Ok(harness::RunReport { + candidate: "C1 egui 0.35 + lyon 1.0 (egui_wgpu::Renderer)".to_string(), + adapter: adapter_info(adapter), + msaa_samples: MSAA, + aa_mechanism: AA_MECHANISM.to_string(), + target_format: format!("{FORMAT:?}"), + luminance_threshold: LUMA_THRESHOLD, + fill_rule: "NonZero".to_string(), + glyphs, + notes: vec![ + "Rendered through egui's own paint pipeline (egui_wgpu::Renderer::update_buffers + \ + ::render) with an epaint::Mesh, NOT a hand-rolled wgpu pipeline — the stack under \ + test is the real one." + .to_string(), + "Whole outline tessellated as ONE compound lyon path in one tessellate_path call." + .to_string(), + "Format deviation: Rgba8Unorm, not sRGB, to match C2 (vello requires it).".to_string(), + "AA is nominally 8x on both candidates — the highest sample count both name, so pin \ + 4's identical-configuration requirement is met as stated. The mechanisms are NOT the \ + same: this is a hardware multisample render-target attachment (hence the \ + TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES request), while C2 uses vello's \ + compute-shader AA into a sample_count:1 storage texture. Matching integers do not \ + imply matching work or matching cost; this is immaterial to Round 1's capability \ + verdict and material to Round 4's timings." + .to_string(), + ], + }) +} + +fn main() -> Result<()> { + let oracle = harness::load_oracle(); + // Semantic validation before anything renders: an oracle that deserializes + // but no longer means what Round 1 requires would be tested faithfully and + // pass (harness `OracleFile::validate`). + oracle + .validate() + .map_err(|e| anyhow!("oracle failed validation: {e}"))?; + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + + let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN)); + + // Pin 4 requires BOTH adapter classes, and the integrated figure is the one + // that decides. Reporting overall PASS after testing whichever adapter + // happened to enumerate would silently narrow the claim, so a missing class + // is NOT RUN (an environment absence) and never a pass. + let has_discrete = adapters + .iter() + .any(|a| a.get_info().device_type == wgpu::DeviceType::DiscreteGpu); + let has_integrated = adapters + .iter() + .any(|a| a.get_info().device_type == wgpu::DeviceType::IntegratedGpu); + if !has_discrete || !has_integrated { + let found: Vec = adapters + .iter() + .map(|a| { + let i = a.get_info(); + format!("{} ({:?})", i.name, i.device_type) + }) + .collect(); + return Err(anyhow!( + "NOT RUN: pin 4 requires one discrete and one integrated Vulkan adapter; found \ + {found:?}. This is an environment absence, not a candidate failure — re-run where \ + both are present." + )); + } + + let mut all_pass = true; + for adapter in &adapters { + let report = run_on(adapter, &oracle)?; + print!("{}", report.table()); + let total: usize = report.glyphs.iter().map(|g| g.points.len()).sum(); + let passed: usize = report + .glyphs + .iter() + .map(|g| g.points.iter().filter(|p| p.pass).count()) + .sum(); + println!( + "RESULT {} :: {passed}/{total} points PASS\n", + report.adapter.name + ); + all_pass &= report.all_pass(); + } + + if all_pass { + println!("C1 egui+lyon: PASS on all {} adapter(s)", adapters.len()); + Ok(()) + } else { + Err(anyhow!( + "C1 egui+lyon: FAIL — see the per-point table above" + )) + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml b/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml new file mode 100644 index 0000000..3e69518 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "c2-vello" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Round 1 candidate C2: vello + kurbo, matching probe-vello's Round-0 +# version pin (vello 0.9). Windowless: vello's Renderer/RenderContext do not +# require winit, so this binary drives its own headless wgpu Device/Queue. +# +# kurbo and wgpu are consumed via vello's own re-exports (`vello::kurbo`, +# `vello::wgpu`) rather than as separate top-level dependencies, so there is +# no risk of resolving a kurbo/wgpu version that disagrees with the one +# vello 0.9 was actually built against (vello re-exports `peniko::kurbo`, +# whose version peniko itself pins). + +[dependencies] +round1-harness = { path = "../harness" } +epiphany-layout-ir = { path = "../../../../crates/epiphany-layout-ir" } +vello = "0.9" +pollster = "0.4" +bytemuck = "1" +anyhow = "1" diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/main.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/main.rs new file mode 100644 index 0000000..0754796 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/main.rs @@ -0,0 +1,351 @@ +//! Round 1 candidate **C2 — vello + kurbo**. +//! +//! Renders each Round-1 glyph's typed `PathCommand` outline through vello's +//! own scene/renderer pipeline to an offscreen texture, then hands the RGBA +//! readback to the candidate-neutral harness for classification against the +//! **frozen** `round1-oracle/oracle.json`. This binary never reads or writes +//! that oracle directly — the harness owns it, read-only. +//! +//! Windowless by construction: vello's `Renderer::render_to_texture` needs no +//! surface, so no `winit` appears here even though C2's Round-0 accessibility +//! route did use it. +//! +//! **The whole outline goes into one `BezPath` and one `Scene::fill` call.** +//! That is the point of criterion 1: a compound path with several contours, +//! filled as one shape, so bounded counters must survive as holes and disjoint +//! components must all be painted. Filling each subpath separately would paint +//! counters solid and quietly pass the test it exists to fail. + +use anyhow::{anyhow, Result}; +use epiphany_layout_ir::PathCommand; +use round1_harness as harness; +use vello::kurbo::{Affine, BezPath, Point as KPoint}; +use vello::peniko::{color::palette, Color, Fill}; +use vello::wgpu; +use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene}; + +/// Opaque black ink on an opaque white ground — the harness classifies by +/// luminance against this contrast, and the oracle's >= 8 device-px clearance +/// guarantees every sample lands fully saturated. +const INK: Color = palette::css::BLACK; +const GROUND: Color = palette::css::WHITE; + +/// Rec. 601 luma threshold used by the harness. Stated, not implied, so a +/// FAIL's root cause can never be "which threshold did you mean". +const LUMA_THRESHOLD: u8 = 128; + +/// **Nominally 8x — the highest sample count BOTH candidates name**, per pin +/// 4's "identical MSAA sample count... the highest all survivors support". +/// vello's `AaConfig` offers only Area / Msaa8 / Msaa16, so 8x is the ceiling +/// on this side, and C1 now matches that integer rather than the 4x it ran +/// earlier. +/// +/// **The integers match; the mechanisms do not, and this is not a footnote.** +/// C1's 8x is hardware multisampling — a `sample_count: 8` colour attachment +/// resolved by the GPU, which is precisely why C1 has to request +/// `TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES` (8x on `Rgba8Unorm` is off the +/// WebGPU baseline). vello's `Msaa8` is its own compute-shader antialiasing, +/// writing into the `sample_count: 1` storage texture created below; there is +/// no multisample attachment anywhere on this side. So pin 4's identical- +/// configuration requirement is met **as stated** — same declared sample count +/// — but equal integers do not imply equal work, equal memory traffic, or +/// equal cost. Round 1 is a capability round and is indifferent to that; Round +/// 4 is not, because AA lands directly in the deciding latency numbers. The +/// report carries the mechanism next to the number so no later round can read +/// "8 == 8" as "same thing measured". +const AA: AaConfig = AaConfig::Msaa8; + +/// Printed beside `msaa_samples` in the run report, so the record itself +/// states the mechanism rather than leaving the bare integer to imply parity. +const AA_MECHANISM: &str = "vello compute AA into a sample_count:1 storage texture"; + +/// vello's `render_to_texture` requires `Rgba8Unorm` + `STORAGE_BINDING`, so +/// both candidates are pinned to this same non-sRGB format for a fair +/// comparison. Immaterial to fill correctness (0 and 255 map to themselves +/// under any transfer function) but a literal deviation from pin 4's "sRGB +/// target format", and reported as one. +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Builds one `kurbo::BezPath` carrying **every** contour of the outline. +/// +/// Staff-space is y-up and device space is y-down, so the y axis is flipped +/// here rather than in a transform, keeping the path in final device +/// coordinates and matching the oracle's own +/// `device = (staff.x * scale + tx, ty - staff.y * scale)` rule exactly. +fn build_path(outline: &[PathCommand], t: &harness::Transform) -> BezPath { + let map = |p: epiphany_layout_ir::Point| -> KPoint { + KPoint::new(p.x.0 as f64 * t.scale + t.tx, t.ty - p.y.0 as f64 * t.scale) + }; + let mut path = BezPath::new(); + let mut open = false; + for cmd in outline { + match cmd { + PathCommand::MoveTo(p) => { + if open { + path.close_path(); + } + path.move_to(map(*p)); + open = true; + } + PathCommand::LineTo(p) => path.line_to(map(*p)), + PathCommand::CurveTo { + control1, + control2, + to, + } => path.curve_to(map(*control1), map(*control2), map(*to)), + PathCommand::Close => { + path.close_path(); + open = false; + } + } + } + if open { + path.close_path(); + } + path +} + +/// Copies a rendered texture back to host memory as tightly packed RGBA, +/// undoing wgpu's 256-byte row-stride padding. +fn readback( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Result> { + let unpadded = width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("c2-readback"), + size: (padded as u64) * (height as u64), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("c2-copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + + let slice = buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + device.poll(wgpu::PollType::wait_indefinitely())?; + rx.recv() + .map_err(|e| anyhow!("readback channel closed: {e}"))? + .map_err(|e| anyhow!("buffer map failed: {e}"))?; + + let mapped = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded as usize) * (height as usize)); + for row in 0..height as usize { + let start = row * padded as usize; + out.extend_from_slice(&mapped[start..start + unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + Ok(out) +} + +fn adapter_info(a: &wgpu::Adapter) -> harness::AdapterInfo { + let info = a.get_info(); + harness::AdapterInfo { + name: info.name.clone(), + backend: format!("{:?}", info.backend), + device_type: format!("{:?}", info.device_type), + vendor_id: info.vendor, + device_id: info.device, + } +} + +fn run_on(adapter: &wgpu::Adapter, oracle: &harness::OracleFile) -> Result { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("c2-vello"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }))?; + + let mut renderer = Renderer::new( + &device, + RendererOptions { + use_cpu: false, + antialiasing_support: AaSupport::all(), + num_init_threads: None, + pipeline_cache: None, + }, + ) + .map_err(|e| anyhow!("vello Renderer::new failed: {e}"))?; + + let mut glyphs = Vec::new(); + for g in &oracle.glyphs { + let width = g.transform.target_width as u32; + let height = g.transform.target_height as u32; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("c2-target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let outline = harness::outline_for(&g.name); + let path = build_path(&outline, &g.transform); + + let mut scene = Scene::new(); + // NonZero: Bravura's contours are correctly oppositely wound, so + // even-odd and nonzero agree on every bundled counter — the oracle + // records that measurement. The fill *rule* is not what is under test; + // preserving contours and counters is. + scene.fill(Fill::NonZero, Affine::IDENTITY, INK, None, &path); + + renderer + .render_to_texture( + &device, + &queue, + &scene, + &view, + &RenderParams { + base_color: GROUND, + width, + height, + antialiasing_method: AA, + }, + ) + .map_err(|e| anyhow!("{}: vello render_to_texture failed: {e}", g.name))?; + + let rgba = readback(&device, &queue, &texture, width, height)?; + glyphs.push( + harness::evaluate_glyph(g, width, height, &rgba, LUMA_THRESHOLD) + .map_err(|e| anyhow!("{e}"))?, + ); + } + + Ok(harness::RunReport { + candidate: "C2 vello 0.9 + kurbo".to_string(), + adapter: adapter_info(adapter), + msaa_samples: 8, + aa_mechanism: AA_MECHANISM.to_string(), + target_format: format!("{FORMAT:?}"), + luminance_threshold: LUMA_THRESHOLD, + fill_rule: "NonZero".to_string(), + glyphs, + notes: vec![ + "AA is nominally 8x on both candidates — the highest sample count both name, so pin \ + 4's identical-configuration requirement is met as stated. The mechanisms are NOT the \ + same: C1 uses a hardware multisample render-target attachment (hence its \ + TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES request), C2 uses vello's compute-shader AA \ + into a sample_count:1 storage texture. Matching integers do not imply matching work \ + or matching cost; this is immaterial to Round 1's capability verdict and material to \ + Round 4's timings." + .to_string(), + "Format deviation: Rgba8Unorm, not sRGB — vello's render_to_texture requires \ + Rgba8Unorm + STORAGE_BINDING. Both candidates pinned to it for fairness; immaterial \ + to fill correctness since only pure black/white are drawn." + .to_string(), + "Whole outline filled as ONE compound BezPath in one Scene::fill call.".to_string(), + ], + }) +} + +fn main() -> Result<()> { + let oracle = harness::load_oracle(); + // Semantic validation before anything renders: an oracle that deserializes + // but no longer means what Round 1 requires would be tested faithfully and + // pass (harness `OracleFile::validate`). + oracle + .validate() + .map_err(|e| anyhow!("oracle failed validation: {e}"))?; + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + + let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN)); + + // Pin 4 requires BOTH adapter classes, and the integrated figure is the one + // that decides. Reporting overall PASS after testing whichever adapter + // happened to enumerate would silently narrow the claim, so a missing class + // is NOT RUN (an environment absence) and never a pass. + let has_discrete = adapters + .iter() + .any(|a| a.get_info().device_type == wgpu::DeviceType::DiscreteGpu); + let has_integrated = adapters + .iter() + .any(|a| a.get_info().device_type == wgpu::DeviceType::IntegratedGpu); + if !has_discrete || !has_integrated { + let found: Vec = adapters + .iter() + .map(|a| { + let i = a.get_info(); + format!("{} ({:?})", i.name, i.device_type) + }) + .collect(); + return Err(anyhow!( + "NOT RUN: pin 4 requires one discrete and one integrated Vulkan adapter; found \ + {found:?}. This is an environment absence, not a candidate failure — re-run where \ + both are present." + )); + } + + let mut all_pass = true; + for adapter in &adapters { + let report = run_on(adapter, &oracle)?; + print!("{}", report.table()); + let total: usize = report.glyphs.iter().map(|g| g.points.len()).sum(); + let passed: usize = report + .glyphs + .iter() + .map(|g| g.points.iter().filter(|p| p.pass).count()) + .sum(); + println!( + "RESULT {} :: {passed}/{total} points PASS\n", + report.adapter.name + ); + all_pass &= report.all_pass(); + } + + if all_pass { + println!("C2 vello: PASS on all {} adapter(s)", adapters.len()); + Ok(()) + } else { + Err(anyhow!("C2 vello: FAIL — see the per-point table above")) + } +} diff --git a/spikes/editor-toolkit/round1-candidates/harness/Cargo.toml b/spikes/editor-toolkit/round1-candidates/harness/Cargo.toml new file mode 100644 index 0000000..d31c221 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/harness/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "round1-harness" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Round 1 shared harness (CONTRACT_EDITOR_T4_SPIKE.md, Round 1: "criterion 1, +# compound-path fill correctness"). Candidate-neutral: loads the frozen +# round1-oracle/oracle.json, fetches typed outlines from BravuraGlyphCatalog, +# and provides the pixel-classification / report-table plumbing shared by +# both candidate binaries (c1-egui-lyon, c2-vello). This crate carries no +# GPU/wgpu/windowing dependency of its own — each candidate binary owns its +# own render path, per the contract's "the stack under test is the real one" +# requirement; this crate only reads oracle data and post-processes a readback +# buffer either candidate hands it. + +[dependencies] +epiphany-glyphs = { path = "../../../../crates/epiphany-glyphs" } +epiphany-layout-ir = { path = "../../../../crates/epiphany-layout-ir" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/spikes/editor-toolkit/round1-candidates/harness/src/lib.rs b/spikes/editor-toolkit/round1-candidates/harness/src/lib.rs new file mode 100644 index 0000000..9f13af4 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/harness/src/lib.rs @@ -0,0 +1,638 @@ +//! Round 1 shared harness: oracle loading + pixel-classification / report +//! plumbing common to both candidate binaries (`c1-egui-lyon`, +//! `c2-vello`). See the crate doc comment in `Cargo.toml` for why this crate +//! carries no GPU/wgpu dependency of its own. + +use std::path::PathBuf; + +use epiphany_glyphs::BravuraGlyphCatalog; +use epiphany_layout_ir::{GlyphCatalog, PathCommand}; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------- +// Oracle model (mirrors round1-oracle's `GlyphOracle` JSON shape exactly; +// this crate does NOT depend on the round1-oracle crate itself — it is a +// frozen, committed artifact, and re-deriving its Rust types independently +// here means a shape drift is a deserialization error, not a silent +// coupling). +// +// `deny_unknown_fields` is what actually makes that true: serde IGNORES +// unknown fields by default, so without it a field added to the oracle would +// deserialize silently and the "drift is an error" claim above would be +// false. Structural agreement is checked here; SEMANTIC agreement — that the +// oracle is internally coherent and complete — is checked by +// `OracleFile::validate`, which every candidate must call before rendering. +// --------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OracleFile { + pub contract: String, + pub round: String, + pub render_transform_rule: String, + pub flatten_tolerance_staff_space: f64, + pub clearance_floor_device_px: f64, + pub glyphs: Vec, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub enum Requirement { + BoundedHole, + DisjointComponents, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub enum SampleClass { + Ink, + Background, +} + +#[derive(Copy, Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transform { + pub scale: f64, + pub tx: f64, + pub ty: f64, + pub target_width: f64, + pub target_height: f64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HoleEvidence { + pub inside_outer_contour: bool, + pub even_odd_filled: bool, + pub nonzero_filled: bool, + pub outer_contour_ring_index: usize, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SamplePoint { + pub staff: (f64, f64), + pub device: (f64, f64), + pub class: SampleClass, + pub clearance_device_px: f64, + pub hole_evidence: Option, + pub subpath_index: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HoleDiagnostic { + pub raw_hole_grid_hits: u64, + pub best_hole_clearance_device_px: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GlyphOracle { + pub name: String, + pub requirement: Requirement, + pub subpath_count: usize, + pub expected_subpath_count: Option, + pub transform: Transform, + pub bbox_staff: [f64; 4], + pub outer_contour_ring_index: usize, + pub ring_signed_areas: Vec, + pub points: Vec, + pub ink_candidates_found: usize, + pub background_candidates_found: usize, + pub ink_spacing_relaxed: bool, + pub background_spacing_relaxed: bool, + pub ink_satisfied: bool, + pub background_required: bool, + pub background_satisfied: bool, + pub subpath_coverage_required: bool, + pub subpath_coverage_satisfied: bool, + pub satisfied: bool, + pub hole_diagnostic: HoleDiagnostic, +} + +// --------------------------------------------------------------------- +// The Round 1 roster, restated here as literals. +// +// These are NOT read from the oracle — restating them is the entire point. +// A validator that only checks the oracle against itself accepts any +// self-consistent oracle, including one with a glyph deleted, a glyph renamed, +// or every target resized together. The contract (Revision 6) names this exact +// roster, so the harness names it too, and a candidate refuses to render +// against anything else. +// --------------------------------------------------------------------- + +/// `(name, requirement, subpath_count, point_count)` — CONTRACT_EDITOR_T4_SPIKE +/// Revision 6, Round 1. Four bounded-hole glyphs at 3 ink + 3 hole background +/// each, plus `fClef` at one tagged ink point per disjoint component. +const ROUND1_ROSTER: [(&str, Requirement, usize, usize); 5] = [ + ("gClef", Requirement::BoundedHole, 4, 6), + ("timeSig8", Requirement::BoundedHole, 3, 6), + ("accidentalFlat", Requirement::BoundedHole, 2, 6), + ("noteheadHalf", Requirement::BoundedHole, 2, 6), + ("fClef", Requirement::DisjointComponents, 3, 3), +]; + +/// Total sample points across the roster: 4 x 6 + 3. Checked as a census so a +/// point silently dropped from one glyph cannot be absorbed by the per-glyph +/// minimums. +const ROUND1_POINT_CENSUS: usize = 27; + +/// Pin 4's offscreen target. Exact, not "whatever the oracle agrees on with +/// itself": scaling every glyph's target together stays self-consistent while +/// changing the rasterization the round is meant to compare. +const TARGET_WIDTH: f64 = 1920.0; +const TARGET_HEIGHT: f64 = 1080.0; + +/// Minimum distance from any sample point to the nearest outline edge. This is +/// what makes the >= 8 px claim load-bearing: below it, a sample can land in +/// the antialiased band, where a correct render legitimately produces a +/// mid-grey and the luminance threshold decides the verdict instead of the +/// geometry. +const CLEARANCE_FLOOR_DEVICE_PX: f64 = 8.0; + +/// Loads the frozen `round1-oracle/oracle.json` relative to this crate's +/// manifest directory (`round1-candidates/harness/../../round1-oracle`). +/// Never writes to that path; read-only. +pub fn load_oracle() -> OracleFile { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("../../round1-oracle/oracle.json"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read oracle at {}: {e}", path.display())); + serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("failed to parse oracle at {}: {e}", path.display())) +} + +impl OracleFile { + /// Checks the oracle is internally coherent and complete **before** any + /// candidate renders against it. + /// + /// `deny_unknown_fields` catches *structural* drift; this catches semantic + /// drift, which is the dangerous kind: an oracle that deserializes cleanly + /// but whose expectations no longer mean what Round 1 requires would be + /// tested against faithfully and pass, and the run would look green. + /// + /// Every expectation below is checked against a **literal restated here** + /// (`ROUND1_ROSTER`, `TARGET_WIDTH`/`_HEIGHT`, `CLEARANCE_FLOOR_DEVICE_PX`, + /// `ROUND1_POINT_CENSUS`) rather than against the oracle's own other + /// fields. An earlier version compared each glyph's target to the *first + /// glyph's* target and each subpath count to the oracle's own + /// `expected_subpath_count`, which accepts any self-consistent file: + /// deleting `noteheadHalf`, renaming a glyph, or resizing every target + /// together all validated cleanly. + /// + /// Enforced: + /// - the glyph set is exactly the five roster names, no duplicates, each + /// with its roster requirement, subpath count, and point count, and 27 + /// points in total; + /// - every target is exactly 1920x1080; + /// - the declared clearance floor is 8 px and every sample meets it; + /// - no glyph's point search fell back to relaxed spacing; + /// - every glyph is `satisfied`, with `ink_satisfied` and the + /// requirement-specific status flags coherent for its class; + /// - hole glyphs carry >= 3 ink and >= 3 bounded-hole background points, + /// with hole evidence on each; + /// - `fClef`-class glyphs carry no background requirement and >= 1 tagged + /// ink point in **every** subpath. + pub fn validate(&self) -> Result<(), String> { + if self.round != "round1" { + return Err(format!( + "oracle declares round {:?}, not \"round1\"", + self.round + )); + } + if self.clearance_floor_device_px != CLEARANCE_FLOOR_DEVICE_PX { + return Err(format!( + "oracle declares a clearance floor of {} device px, not {CLEARANCE_FLOOR_DEVICE_PX} \ + — lowering it lets samples sit in the antialiased band, where the threshold, not \ + the geometry, decides the verdict", + self.clearance_floor_device_px + )); + } + + // Exact set equality, in both directions: same length, every roster + // name present, and no name appearing twice (so a duplicate cannot + // stand in for a deleted glyph and keep the count right). + if self.glyphs.len() != ROUND1_ROSTER.len() { + let names: Vec<&str> = self.glyphs.iter().map(|g| g.name.as_str()).collect(); + return Err(format!( + "oracle carries {} glyphs {names:?}, not the {} Round 1 requires", + self.glyphs.len(), + ROUND1_ROSTER.len() + )); + } + for (i, g) in self.glyphs.iter().enumerate() { + if self.glyphs[..i].iter().any(|o| o.name == g.name) { + return Err(format!("glyph {:?} appears more than once", g.name)); + } + } + let total_points: usize = self.glyphs.iter().map(|g| g.points.len()).sum(); + if total_points != ROUND1_POINT_CENSUS { + return Err(format!( + "oracle carries {total_points} sample points in total, not the \ + {ROUND1_POINT_CENSUS} Round 1 requires" + )); + } + + for (name, requirement, subpaths, point_count) in ROUND1_ROSTER { + let g = self + .glyphs + .iter() + .find(|g| g.name == name) + .ok_or_else(|| format!("oracle is missing required Round 1 glyph {name:?}"))?; + let at = |m: String| format!("{}: {m}", g.name); + + if g.requirement != requirement { + return Err(at(format!( + "carries requirement {:?}, but Round 1 tests it for {requirement:?}", + g.requirement + ))); + } + if g.subpath_count != subpaths || g.expected_subpath_count != Some(subpaths) { + return Err(at(format!( + "subpath_count {} / expected {:?} disagrees with the {subpaths} subpaths \ + Round 1 names for this glyph", + g.subpath_count, g.expected_subpath_count + ))); + } + if g.points.len() != point_count { + return Err(at(format!( + "carries {} sample points, not the {point_count} Round 1 names", + g.points.len() + ))); + } + if !g.satisfied || !g.ink_satisfied { + return Err(at(format!( + "oracle records satisfied = {} / ink_satisfied = {}", + g.satisfied, g.ink_satisfied + ))); + } + if g.ink_spacing_relaxed || g.background_spacing_relaxed { + return Err(at( + "point search fell back to relaxed spacing — the recorded points are closer \ + together than the round's design, so they no longer probe independent regions" + .to_string(), + )); + } + if g.transform.target_width != TARGET_WIDTH + || g.transform.target_height != TARGET_HEIGHT + { + return Err(at(format!( + "target is {}x{}, not pin 4's {TARGET_WIDTH}x{TARGET_HEIGHT}", + g.transform.target_width, g.transform.target_height + ))); + } + for (i, p) in g.points.iter().enumerate() { + if !(p.clearance_device_px >= CLEARANCE_FLOOR_DEVICE_PX) { + return Err(at(format!( + "point {i} at device ({}, {}) has clearance {} device px, below the \ + {CLEARANCE_FLOOR_DEVICE_PX} px floor", + p.device.0, p.device.1, p.clearance_device_px + ))); + } + } + + let ink: Vec<&SamplePoint> = g + .points + .iter() + .filter(|p| p.class == SampleClass::Ink) + .collect(); + let bg: Vec<&SamplePoint> = g + .points + .iter() + .filter(|p| p.class == SampleClass::Background) + .collect(); + + match g.requirement { + Requirement::BoundedHole => { + if !g.background_required + || !g.background_satisfied + || g.subpath_coverage_required + { + return Err(at(format!( + "BoundedHole must require and satisfy background points and must not \ + claim subpath coverage; has background_required = {}, \ + background_satisfied = {}, subpath_coverage_required = {}", + g.background_required, + g.background_satisfied, + g.subpath_coverage_required + ))); + } + if ink.len() < 3 || bg.len() < 3 { + return Err(at(format!( + "BoundedHole needs >= 3 ink and >= 3 background points, has {} and {}", + ink.len(), + bg.len() + ))); + } + for p in &bg { + let e = p.hole_evidence.as_ref().ok_or_else(|| { + at("background point carries no hole evidence".to_string()) + })?; + // Unfilled under BOTH rules, not just even-odd: that + // agreement is the measured fact criterion 1 rests on + // (Bravura's contours are correctly oppositely wound), + // so a point the two rules disagree about is not a + // bounded hole this round can test with. + if !e.inside_outer_contour || e.even_odd_filled || e.nonzero_filled { + return Err(at(format!( + "background point at device ({}, {}) is not an unambiguously \ + bounded hole: inside_outer_contour = {}, even_odd_filled = {}, \ + nonzero_filled = {}", + p.device.0, + p.device.1, + e.inside_outer_contour, + e.even_odd_filled, + e.nonzero_filled + ))); + } + if e.outer_contour_ring_index != g.outer_contour_ring_index { + return Err(at(format!( + "background point names outer ring {} but the glyph names {}", + e.outer_contour_ring_index, g.outer_contour_ring_index + ))); + } + } + } + Requirement::DisjointComponents => { + if g.background_required + || !g.subpath_coverage_required + || !g.subpath_coverage_satisfied + { + return Err(at( + "DisjointComponents must require subpath coverage and no background" + .to_string(), + )); + } + if !bg.is_empty() { + return Err(at(format!( + "DisjointComponents must carry no background points, has {}", + bg.len() + ))); + } + let mut covered: Vec = ink + .iter() + .filter_map(|p| p.subpath_index) + .collect::>(); + covered.sort_unstable(); + covered.dedup(); + if covered.len() != g.subpath_count + || covered.first() != Some(&0) + || covered.last() != Some(&(g.subpath_count - 1)) + { + return Err(at(format!( + "ink points cover subpaths {covered:?}, not every index in \ + 0..{} — the check that catches a largest-contour-only tessellator \ + would not fire", + g.subpath_count + ))); + } + } + } + } + Ok(()) + } +} + +/// Fetches one glyph's typed outline from the real `BravuraGlyphCatalog` — +/// the same catalog `round1-oracle` derived its geometry from. +pub fn outline_for(name: &str) -> Vec { + let catalog = BravuraGlyphCatalog; + catalog + .render_data(name) + .unwrap_or_else(|| panic!("{name}: BravuraGlyphCatalog has no render data")) + .outline +} + +// --------------------------------------------------------------------- +// Pixel classification + report model +// --------------------------------------------------------------------- + +/// Ink-vs-background classification of one sampled RGBA pixel. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PixelClass { + Ink, + Background, +} + +/// Classifies `rgba` by luminance against `threshold` (0..=255): a pixel +/// whose luminance is strictly below `threshold` is `Ink` (opaque black +/// fill), otherwise `Background` (opaque white). Since every oracle sample +/// point sits >= 8 device px from any outline edge, every real sample should +/// land solidly at (0,0,0,255) or (255,255,255,255) — the exact threshold +/// value barely matters for a passing render, but a stated one is required +/// so a FAIL's root cause is never "which threshold did you mean". +pub fn classify_pixel(rgba: [u8; 4], threshold: u8) -> PixelClass { + let [r, g, b, _a] = rgba; + // Standard luma weights (Rec. 601), integer-approximated. + let luma = (299 * r as u32 + 587 * g as u32 + 114 * b as u32) / 1000; + if luma < threshold as u32 { + PixelClass::Ink + } else { + PixelClass::Background + } +} + +pub fn expected_class(oracle_class: SampleClass) -> PixelClass { + match oracle_class { + SampleClass::Ink => PixelClass::Ink, + SampleClass::Background => PixelClass::Background, + } +} + +#[derive(Clone, Debug)] +pub struct PointResult { + pub staff: (f64, f64), + pub device: (f64, f64), + pub oracle_class: SampleClass, + pub subpath_index: Option, + pub sampled_rgba: [u8; 4], + pub actual_class: PixelClass, + pub expected_class: PixelClass, + pub pass: bool, +} + +#[derive(Clone, Debug)] +pub struct GlyphResult { + pub name: String, + pub points: Vec, +} + +impl GlyphResult { + pub fn all_pass(&self) -> bool { + self.points.iter().all(|p| p.pass) + } +} + +#[derive(Clone, Debug)] +pub struct AdapterInfo { + pub name: String, + pub backend: String, + pub device_type: String, + pub vendor_id: u32, + pub device_id: u32, +} + +#[derive(Clone, Debug)] +pub struct RunReport { + pub candidate: String, + pub adapter: AdapterInfo, + /// The **nominal** sample count. Both candidates report 8, as pin 4 + /// requires, but the number alone overstates the agreement — see + /// `aa_mechanism`, which is printed beside it for exactly that reason. + pub msaa_samples: u32, + /// How that sample count is actually achieved. C1 uses a hardware + /// multisample render-target attachment; C2 uses vello's compute-shader + /// antialiasing into a single-sample storage texture. Recording only the + /// integer would let a later round read "8 == 8" as "same work", which it + /// is not — and at Round 4 that difference is in the deciding numbers. + pub aa_mechanism: String, + pub target_format: String, + pub luminance_threshold: u8, + pub fill_rule: String, + pub glyphs: Vec, + pub notes: Vec, +} + +impl RunReport { + pub fn all_pass(&self) -> bool { + self.glyphs.iter().all(|g| g.all_pass()) + } + + /// Renders the full per-glyph x per-point table as plain text. + pub fn table(&self) -> String { + let mut out = String::new(); + out.push_str(&format!( + "=== {} on {} ({}) [backend={}, vendor=0x{:04x}, device=0x{:04x}] ===\n", + self.candidate, + self.adapter.name, + self.adapter.device_type, + self.adapter.backend, + self.adapter.vendor_id, + self.adapter.device_id, + )); + out.push_str(&format!( + "msaa={} ({}) format={} luminance_threshold<{} fill_rule={}\n", + self.msaa_samples, + self.aa_mechanism, + self.target_format, + self.luminance_threshold, + self.fill_rule + )); + for g in &self.glyphs { + out.push_str(&format!( + "--- {} ({}/{} points PASS) ---\n", + g.name, + g.points.iter().filter(|p| p.pass).count(), + g.points.len() + )); + out.push_str( + " idx class subpath device(x,y) rgba verdict\n", + ); + for (i, p) in g.points.iter().enumerate() { + out.push_str(&format!( + " {:>3} {:<10} {:<7} ({:>8.2},{:>8.2}) ({:>3},{:>3},{:>3},{:>3}) {}\n", + i, + format!("{:?}", p.oracle_class), + p.subpath_index + .map(|s| s.to_string()) + .unwrap_or_else(|| "-".to_string()), + p.device.0, + p.device.1, + p.sampled_rgba[0], + p.sampled_rgba[1], + p.sampled_rgba[2], + p.sampled_rgba[3], + if p.pass { "PASS" } else { "FAIL" }, + )); + } + } + if !self.notes.is_empty() { + out.push_str("notes:\n"); + for n in &self.notes { + out.push_str(&format!(" - {n}\n")); + } + } + out + } +} + +/// Rounds a device-space coordinate to the nearest pixel index, **erroring +/// rather than clamping** when it falls outside `[0, dim)`. +/// +/// Clamping was the earlier behaviour and it is unsafe here: an out-of-range +/// coordinate silently becomes an edge pixel, which for a glyph centred in a +/// 1920x1080 target is always background — so a mis-transformed ink point +/// would read as a candidate FAIL, and a mis-transformed background point as a +/// PASS. Either way the harness would be reporting on a pixel the oracle never +/// named. +pub fn device_index(v: f64, dim: u32) -> Result { + let r = v.round(); + if r < 0.0 || r >= dim as f64 { + return Err(format!( + "device coordinate {v} rounds to {r}, outside [0, {dim}) — the render target does not \ + cover the oracle's sample point" + )); + } + Ok(r as u32) +} + +/// Builds a `GlyphResult` from an already-rendered RGBA readback buffer +/// (tightly packed, `width * height * 4` bytes, row-major top-to-bottom — +/// standard wgpu texture-copy layout after unpadding row strides) by +/// sampling each oracle point's `device` pixel. +/// +/// **Every failure mode here is an error, never a substituted sample.** A +/// short buffer previously yielded `(0,0,0,0)`, whose luma is 0 — it +/// classifies as *ink*, so a truncated readback would have made every ink +/// point pass. Buffer length, coordinate range, and sample opacity are all +/// checked, because each of them can turn a broken run into a green one. +pub fn evaluate_glyph( + oracle: &GlyphOracle, + width: u32, + height: u32, + rgba: &[u8], + threshold: u8, +) -> Result { + let expect_len = (width as usize) * (height as usize) * 4; + if rgba.len() != expect_len { + return Err(format!( + "{}: readback buffer is {} bytes, expected exactly {expect_len} ({width}x{height} \ + RGBA) — a short or padded buffer cannot be sampled safely", + oracle.name, + rgba.len() + )); + } + let mut points = Vec::with_capacity(oracle.points.len()); + for p in &oracle.points { + let px = device_index(p.device.0, width).map_err(|e| format!("{}: x: {e}", oracle.name))?; + let py = + device_index(p.device.1, height).map_err(|e| format!("{}: y: {e}", oracle.name))?; + let idx = ((py as usize) * (width as usize) + (px as usize)) * 4; + let sampled = [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]; + if sampled[3] != 255 { + return Err(format!( + "{}: sample at device ({}, {}) has alpha {} — the target must be fully opaque; a \ + transparent sample means the clear or the blend is wrong, not that the candidate \ + drew the wrong colour", + oracle.name, p.device.0, p.device.1, sampled[3] + )); + } + let actual = classify_pixel(sampled, threshold); + let expected = expected_class(p.class); + points.push(PointResult { + staff: p.staff, + device: p.device, + oracle_class: p.class, + subpath_index: p.subpath_index, + sampled_rgba: sampled, + actual_class: actual, + expected_class: expected, + pass: actual == expected, + }); + } + Ok(GlyphResult { + name: oracle.name.clone(), + points, + }) +}