From e409107a63e19f52ad32fcbdbe8cedf75cf7df27 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 19:16:43 -0700 Subject: [PATCH] experiment: prepare real WGPU Scenario label evaluator --- .../scenario-label-wgpu-20260913/README.md | 79 ++++ .../build-and-run.ps1 | 109 ++++++ .../scenario-label-wgpu-20260913/main.rs | 350 ++++++++++++++++++ 3 files changed, 538 insertions(+) create mode 100644 harness/experiments/scenario-label-wgpu-20260913/README.md create mode 100644 harness/experiments/scenario-label-wgpu-20260913/build-and-run.ps1 create mode 100644 harness/experiments/scenario-label-wgpu-20260913/main.rs diff --git a/harness/experiments/scenario-label-wgpu-20260913/README.md b/harness/experiments/scenario-label-wgpu-20260913/README.md new file mode 100644 index 00000000..980320f6 --- /dev/null +++ b/harness/experiments/scenario-label-wgpu-20260913/README.md @@ -0,0 +1,79 @@ +# Actual WGPU label-ink evaluator — 2026-09-13 + +Status: authored, **not compiled or executed**. Source inspected at `345f7b3`, evidence checkout `2f90cf2`; the integration owner must capture exact file hashes when running. Only this new experiment directory changes. The measured-label module, corridor candidate and production renderer remain untouched. + +## Question and competing expectations + +Does a single-line label measured by the pinned Iced text allocator fit its proposed painted backplate and horizontal free interval after the actual canvas camera transform? Layout advances are not glyph-ink bounds. Italic bearings, combining marks, fallback fonts, antialiasing and device scaling can falsify containment despite a successful width measurement. + +The experiment distinguishes three claims: + +1. **Advance-only hypothesis:** every painted pixel lies in the returned layout rectangle. Count strict violations and violations after a one-physical-pixel halo. Violations are evidence, not automatic evaluator failure: the module explicitly does not promise ink containment. +2. **Candidate padding hypothesis:** adding 3 horizontal / 2 vertical world units to that rectangle contains ink, with a separately reported one-physical-pixel halo. This is an unaccepted probe policy. A violation beyond the halo is a finding, not grounds to silently increase the constant. +3. **Corridor/blocker hypothesis:** ink does not extend horizontally beyond the supplied interval plus one physical pixel, and does not change the painted interiors of nearby activation-like blockers. These are fixed independent probes, not the `widest_clear` algorithm or real Scene membership. + +Strict counts remain visible even when the one-pixel halo passes; the halo is an explicit experimental allowance, not proof of zero overlap. Pixel difference threshold is greater than 3 in any 8-bit RGBA channel. We retain raw pixels so weaker ink and alternative tolerances can be investigated without rerendering. + +## Actual path and independent controls + +`main.rs` imports the existing `scenario_label.rs` using `#[path]`, without copying it and without enabling its test module. It creates an actual `iced::Renderer` through `Headless::new(..., Some("wgpu"))`, creates `canvas::Frame`, applies translation then positive uniform scale, calls the label's `canvas_text` followed by `Frame::fill_text`, records `draw_geometry`, and reads actual RGBA using `Headless::screenshot`. No alternate text renderer, native window, screenshot mock, or hand-drawn glyph is involved. + +All 32 cases are evaluated in one dedicated executable process. Global fonts initialize before a fresh renderer and `RenderSession`; no font mutation is allowed until process exit. Each frame resets renderer geometry while retaining its genuine WGPU caches. Cases cover: + +- A realistic existing scenario-style call label, CJK, decomposed combining marks, and an italic label beginning with `jfj`. +- Widths 160 and 760 world units. The wide case must preserve the explicit full source; the narrow case must show an ellipsis and retain a nonempty proper source prefix. +- `(zoom, DPI)` of `(0.65,1)`, `(1,1)`, `(1.75,1)`, `(1,1.5)`. Fractional camera translation and text origins exercise subpixel positioning. + +The independently constructed `canvas::Text` control must paint the same pixels as the guarded payload. A second actual Frame shifts camera translation by exactly 16 physical pixels; its pixels must equal the original shifted by 16. These checks can reveal wrong coordinate assumptions before any ink conclusion. The viewport has generous right/bottom margin; any observed ink touching its boundary invalidates the probe because clipping may have hidden overhang. + +Text-only pixels against white provide the ink mask. A separate decoration render contains yellow backplate then blue blocker rectangles. A composite adds actual text. Actual opaque blue interior pixels must remain unchanged. Because the blockers are painted after the backplate, this specifically evaluates foreground text interference; it does not infer that arbitrary backplate geometry never overlaps a blocker or that every antialiased border remains untouched. The independent world rectangles provide 1 world unit of clearance between a maximally wide padded plate and its blocker. + +Actual shaped glyph IDs and resolved font faces are recorded as a **coverage gate only**. Glyph ID zero, no glyphs, or no resolved italic/oblique face for the italic case invalidates that case. CJK missing-font boxes cannot count as CJK acceptance. The inspected shaping buffer does not supply the ink bounds. System fonts may differ across machines; face identities/families/styles are recorded, but this is not a portable pixel-golden suite or a complete font-file provenance recorder. Pin and hash relevant system font files for a cross-machine comparison. + +## Build and run: integration owner only + +No Cargo or new crate dependencies are required. Compile a normal executable, **not `rustc --test`**, against the exact already-built Iced 0.14.0 and unicode-segmentation artifacts, plus their matching transitive dependency directory. The active Iced artifact must have WGPU, canvas and advanced enabled; tiny-skia fallback is not accepted by this harness. This imports the real candidate but does not wire it into desktop production. + +Equivalent raw invocation, substituting actual absolute paths: + +```powershell +& $rustc --edition=2024 --crate-name scenario_label_wgpu --crate-type bin ` + -C debuginfo=0 -C "linker=$clang" -L "dependency=$deps" -L "native=$nativeLibs" ` + --extern "iced=$icedRlib" --extern "unicode_segmentation=$unicodeRlib" ` + $mainRs -o $newExecutable +& $newExecutable $newPixelDirectory +``` + +`build-and-run.ps1` accepts those paths explicitly and refuses an existing run directory. It hashes the resolved compiler, linker, direct libraries and source inputs, and passes the resolved linker path through `-C linker=...`. For Windows gnullvm, initialize the parent's existing `.local/toolchain/env.ps1`, resolve `$clang = (Get-Command clang.exe).Source`, and set `$nativeLibs` to the repository's `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/windows_x86_64_gnullvm-0.52.6/lib`. This exact native search directory and `linker=clang.exe` appear in the successful parent `.local/scenario-label-gates-attempt4-20260913/compile-receipt.json` (rustc 1.98.1, x86_64-pc-windows-gnullvm). That prior module gate is linkage evidence, not an executed result for this WGPU harness. + +The runner records the complete recursively enumerated Rust link/metadata artifact set (`.rlib`, `.rmeta`, `.dll`, `.lib`, `.a`, `.o`, `.obj`) in the supplied dependency directory and every file in the explicit native directory. It rehashes and compares both manifests after linking, detecting additions, removals or changed content. It separately rechecks source/direct-library/compiler/linker inputs before running. These are artifact supersets, not assertions that every file was linked. It hashes the executable before and after its fresh-process run and preserves an output manifest of generated logs, receipts, executable, metrics and images, including nonzero evaluator outcomes. + +The successful attempt4 used Iced `ee73abd722c72024` and unicode-segmentation `85698043f7780858`. Another Iced artifact `a4e48b220c5d9711` also exists; use the matching tested dependency set rather than treating them as interchangeable. Required runtime DLL paths and a working WGPU adapter remain prerequisites. Adapter/device creation failure is an infrastructure failure, never an ignored pass. + +```powershell +& ./build-and-run.ps1 -Rustc $rustc -IcedRlib $icedRlib ` + -UnicodeSegmentationRlib $unicodeRlib -DependencyDirectory $deps ` + -Linker $clang -NativeLibraryDirectory $nativeLibs ` + -NewRunDirectory $freshRun +``` + +The script is also unexecuted and requires PowerShell 7 (`Path.GetRelativePath`). Compile/run only after acquiring the parent's sole compiler/GPU resource. Before/after hashes do not detect a file temporarily changed and restored during linking; exclusive input ownership remains required. This is not a hermetic toolchain capture: implicitly searched system SDK libraries, clang's auxiliary executables/DLLs, runtime dependencies, system fonts and GPU drivers outside the explicit directories are not all hashed. Preserve that environment separately. The renderer exposes the backend name `wgpu`, not complete adapter identity through `Headless`; retain driver/backend logs and hardware identity separately. `WGPU_BACKEND` is recorded if set. Do not infer native acceptance from this offscreen run. + +## Artifacts and interpretation + +Each case writes `metrics.txt` with source/display/prefix, font faces, coordinates, ink pixel bounding box/counts, control differences and findings. It writes `text`, `decoration` and `composite` as both tightly packed, top-to-bottom RGBA8 `.rgba` and standard P6 `.ppm`. Width/height are in `metrics.txt` and the PPM header. Failed controls also preserve their rendered pixels. No image conversion participates in the oracle. These are real WGPU readbacks; inspect the PPMs or convert the preserved pixels for review after the numeric run. + +Exit 0 means exactly 32 valid probes without a candidate-padding/corridor finding. Exit 2 means a candidate finding. Exit 3 means a coverage or control failure makes at least one probe invalid. Panic, missing summary or fewer than 32 cases is nonacceptance. Every completed run writes `summary.txt`; preserve failures before changing source. Report exact source, executable, artifact and environment provenance alongside any finding. A positive result supports only the specified strings/fonts/scales/padding; no universal raster containment claim follows. + +This is intentionally smaller than Scene integration: no parsed model, occurrence/navigation hit testing, old renderer comparison, real activation/frame paint ordering, self-call or reversed-message corridor selection, clipping near viewport edges, rotation, wrapping, row-height policy, gesture performance, dynamic font replacement, or native user workflow is accepted. Those remain integration evaluators. A finding here should refine the shared measured-label/ink-clearance contract before production callers are added. + +## Pinned primary API evidence + +Registry source root is the repository-local `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/` (outside this worktree): + +- `iced_core-0.14.0/src/renderer.rs`: `Headless::new`, `name`, `screenshot` explicitly return RGBA; `Renderer::reset` resets the frame. +- `iced_wgpu-0.14.0/src/lib.rs:912`: headless creation uses WGPU's actual adapter/device, RGBA render target and MSAAx4; screenshot uses a physical viewport and supplied DPI. +- `iced_wgpu-0.14.0/src/geometry.rs:306`: positive uniform-scale `fill_text` transforms the position, scales font size/absolute line height, and emits `Text::Cached`. Nonuniform/rotated vector fallback is outside this experiment. +- `iced_widget-0.14.2/src/canvas.rs`: canvas draws generated geometry through `geometry::Renderer::draw_geometry`. +- `iced_graphics-0.14.0/src/text/cache.rs`: glyph coverage introspection uses the actual allocator's buffer and resolved fonts. Its metrics are not substituted for screenshots. +- Current desktop `behavior.rs:2158` translates the camera then scales its actual Frame. This evaluator preserves that order without claiming to import the whole Scene. diff --git a/harness/experiments/scenario-label-wgpu-20260913/build-and-run.ps1 b/harness/experiments/scenario-label-wgpu-20260913/build-and-run.ps1 new file mode 100644 index 00000000..35b51d1d --- /dev/null +++ b/harness/experiments/scenario-label-wgpu-20260913/build-and-run.ps1 @@ -0,0 +1,109 @@ +param( + [Parameter(Mandatory = $true)][string]$Rustc, + [Parameter(Mandatory = $true)][string]$IcedRlib, + [Parameter(Mandatory = $true)][string]$UnicodeSegmentationRlib, + [Parameter(Mandatory = $true)][string]$DependencyDirectory, + [Parameter(Mandatory = $true)][string]$Linker, + [Parameter(Mandatory = $true)][string]$NativeLibraryDirectory, + [Parameter(Mandatory = $true)][string]$NewRunDirectory +) +$ErrorActionPreference = 'Stop' +# Only the integration owner may invoke this while holding the compiler/GPU gate. +# No Cargo, dependency fetching, source mutation or existing output replacement. +$labelRustc = (Resolve-Path -LiteralPath $Rustc).Path +$labelIced = (Resolve-Path -LiteralPath $IcedRlib).Path +$labelUnicode = (Resolve-Path -LiteralPath $UnicodeSegmentationRlib).Path +$labelDeps = (Resolve-Path -LiteralPath $DependencyDirectory).Path +$labelLinker = (Resolve-Path -LiteralPath $Linker).Path +$labelNative = (Resolve-Path -LiteralPath $NativeLibraryDirectory).Path +$labelRun = [IO.Path]::GetFullPath($NewRunDirectory) +if (Test-Path -LiteralPath $labelRun) { throw 'Run directory already exists; preserve it and select a new path.' } +$labelMain = Join-Path $PSScriptRoot 'main.rs' +$labelModule = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../crates/oa-desktop/src/behavior/scenario_label.rs')) +$labelScript = Join-Path $PSScriptRoot 'build-and-run.ps1' +New-Item -ItemType Directory -Path $labelRun | Out-Null +$labelExe = Join-Path $labelRun 'scenario-label-wgpu.exe' +$labelArguments = @( + '--edition=2024', '--crate-name', 'scenario_label_wgpu', '--crate-type', 'bin', + '-C', 'debuginfo=0', '-L', ('dependency=' + $labelDeps), + '-C', ('linker=' + $labelLinker), '-L', ('native=' + $labelNative), + '--extern', ('iced=' + $labelIced), + '--extern', ('unicode_segmentation=' + $labelUnicode), + $labelMain, '-o', $labelExe +) +$labelInputs = @($labelRustc, $labelLinker, $labelIced, $labelUnicode, $labelMain, $labelModule, $labelScript) | ForEach-Object { + $labelHash = Get-FileHash -LiteralPath $_ -Algorithm SHA256 + [ordered]@{ path = $_; sha256 = $labelHash.Hash } +} +function Get-LabelArtifactManifest([string]$Directory, [switch]$AllFiles) { + Get-ChildItem -LiteralPath $Directory -File -Recurse | Where-Object { + $AllFiles -or $_.Extension -in @('.rlib', '.rmeta', '.dll', '.lib', '.a', '.o', '.obj') + } | Sort-Object FullName | ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($Directory, $_.FullName) + bytes = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + } +} +# Supersets of Rust link/metadata artifacts and all explicit native-dir inputs, +# not a claim that every inventoried artifact is actually linked. +$labelDependencyManifest = @(Get-LabelArtifactManifest $labelDeps) +$labelNativeManifest = @(Get-LabelArtifactManifest $labelNative -AllFiles) +if ($labelDependencyManifest.Count -eq 0 -or $labelNativeManifest.Count -eq 0) { throw 'Empty dependency or native-library directory.' } +ConvertTo-Json -InputObject $labelDependencyManifest -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'dependency-artifacts-before.json') -Encoding utf8 +ConvertTo-Json -InputObject $labelNativeManifest -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'native-artifacts-before.json') -Encoding utf8 +$labelRecord = [ordered]@{ + scope = 'Standalone actual WGPU canvas label evaluator; not desktop Scene/native acceptance' + startedUtc = [DateTime]::UtcNow.ToString('o') + os = [Environment]::OSVersion.ToString() + rustc = (& $labelRustc --version --verbose | Out-String) + arguments = $labelArguments + inputs = $labelInputs + dependencyDirectory = $labelDeps + nativeLibraryDirectory = $labelNative + WGPU_BACKEND = $env:WGPU_BACKEND + WGPU_ADAPTER_NAME = $env:WGPU_ADAPTER_NAME +} +$labelRecord | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $labelRun 'inputs.json') -Encoding utf8 +& $labelRustc @labelArguments 2>&1 | Tee-Object -FilePath (Join-Path $labelRun 'build.log') +$labelBuildExit = $LASTEXITCODE +$labelDependenciesAfter = @(Get-LabelArtifactManifest $labelDeps) +$labelNativeAfter = @(Get-LabelArtifactManifest $labelNative -AllFiles) +ConvertTo-Json -InputObject $labelDependenciesAfter -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'dependency-artifacts-after.json') -Encoding utf8 +ConvertTo-Json -InputObject $labelNativeAfter -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'native-artifacts-after.json') -Encoding utf8 +$labelDependenciesStable = (ConvertTo-Json -InputObject $labelDependencyManifest -Depth 4 -Compress) -ceq (ConvertTo-Json -InputObject $labelDependenciesAfter -Depth 4 -Compress) +$labelNativeStable = (ConvertTo-Json -InputObject $labelNativeManifest -Depth 4 -Compress) -ceq (ConvertTo-Json -InputObject $labelNativeAfter -Depth 4 -Compress) +[ordered]@{ + exit = $labelBuildExit; dependencyManifestStable = $labelDependenciesStable + nativeManifestStable = $labelNativeStable; endedUtc = [DateTime]::UtcNow.ToString('o') +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $labelRun 'compile-receipt.json') -Encoding utf8 +if ($labelBuildExit -ne 0) { throw "Compilation failed with exit $labelBuildExit; this run is nonacceptance." } +if (!$labelDependenciesStable -or !$labelNativeStable) { throw 'Dependency/native inputs changed during linking; preserve this run as nonacceptance.' } +foreach ($labelInput in $labelInputs) { + if ((Get-FileHash -LiteralPath $labelInput.path -Algorithm SHA256).Hash -ne $labelInput.sha256) { + throw ('Input changed during compilation: ' + $labelInput.path) + } +} +$labelExeHash = (Get-FileHash -LiteralPath $labelExe -Algorithm SHA256).Hash +$labelOutput = Join-Path $labelRun 'pixels' +# A single NEW process owns initialization, settled fonts, renderer and all cases. +& $labelExe $labelOutput 2>&1 | Tee-Object -FilePath (Join-Path $labelRun 'run.log') +$labelRunExit = $LASTEXITCODE +$labelExeAfterHash = (Get-FileHash -LiteralPath $labelExe -Algorithm SHA256).Hash +$labelReceipt = [ordered]@{ + executable = $labelExe; sha256Before = $labelExeHash; sha256After = $labelExeAfterHash + binaryStable = ($labelExeHash -ceq $labelExeAfterHash); exit = $labelRunExit + endedUtc = [DateTime]::UtcNow.ToString('o') + interpretation = '0=32 valid probes without candidate findings; 2=padding/corridor finding; 3=invalid coverage/contract probe; other=infrastructure/panic' +} +$labelReceipt | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'receipt.json') -Encoding utf8 +# Capture all produced evidence, even for exit 2/3 or a missing summary. The +# manifest itself is excluded naturally because it does not exist until written. +$labelOutputManifest = @(Get-LabelArtifactManifest $labelRun -AllFiles) +ConvertTo-Json -InputObject $labelOutputManifest -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'output-artifacts.json') -Encoding utf8 +if ($labelExeHash -cne $labelExeAfterHash) { throw 'Executable changed during execution; this run is nonacceptance.' } +$labelSummary = Join-Path $labelOutput 'summary.txt' +if (!(Test-Path -LiteralPath $labelSummary)) { throw 'No complete summary: infrastructure failure or panic, not acceptance.' } +if ((Get-Content -LiteralPath $labelSummary -Raw) -notmatch '(?m)^cases=32\r?$') { throw 'Expected exactly 32 completed cases.' } +if ($labelRunExit -ne 0) { throw "Evaluator exit $labelRunExit. Preserve metrics/images; diagnose before changing the candidate." } diff --git a/harness/experiments/scenario-label-wgpu-20260913/main.rs b/harness/experiments/scenario-label-wgpu-20260913/main.rs new file mode 100644 index 00000000..195ed771 --- /dev/null +++ b/harness/experiments/scenario-label-wgpu-20260913/main.rs @@ -0,0 +1,350 @@ +//! 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(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, + 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 { + 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) { + 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 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(::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); } +}