experiment: prepare real WGPU Scenario label evaluator
This commit is contained in:
@@ -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.
|
||||||
@@ -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." }
|
||||||
@@ -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<T>(future: impl Future<Output = T>) -> T {
|
||||||
|
struct WakeThread(std::thread::Thread);
|
||||||
|
impl std::task::Wake for WakeThread {
|
||||||
|
fn wake(self: Arc<Self>) { self.0.unpark(); }
|
||||||
|
fn wake_by_ref(self: &Arc<Self>) { self.0.unpark(); }
|
||||||
|
}
|
||||||
|
let waker = std::task::Waker::from(Arc::new(WakeThread(std::thread::current())));
|
||||||
|
let mut context = std::task::Context::from_waker(&waker);
|
||||||
|
let mut future = std::pin::pin!(future);
|
||||||
|
loop {
|
||||||
|
match future.as_mut().poll(&mut context) {
|
||||||
|
std::task::Poll::Ready(value) => return value,
|
||||||
|
std::task::Poll::Pending => std::thread::park(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Pass { Text, ManualText, Decoration, Composite }
|
||||||
|
|
||||||
|
struct Surface {
|
||||||
|
logical: Size,
|
||||||
|
physical: Size<u32>,
|
||||||
|
dpi: f32,
|
||||||
|
zoom: f32,
|
||||||
|
advance: Rectangle,
|
||||||
|
plate: Rectangle,
|
||||||
|
corridor: Rectangle,
|
||||||
|
blockers: [Rectangle; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Surface {
|
||||||
|
fn new(label: &Label, session: &RenderSession, zoom: f32, dpi: f32, width: f32) -> Self {
|
||||||
|
let advance = label.bounds_at(session, zoom, POSITION).unwrap();
|
||||||
|
// Explicit EXPERIMENT candidate: 3 horizontal / 2 vertical world units.
|
||||||
|
// It is not an accepted production padding or an assertion about ink.
|
||||||
|
let plate = grow(advance, 3., 2.);
|
||||||
|
let corridor = Rectangle { width, ..advance };
|
||||||
|
let blockers = [
|
||||||
|
Rectangle { x: POSITION.x - 12., y: POSITION.y - 12., width: 8., height: 52. },
|
||||||
|
Rectangle { x: POSITION.x + width + 4., y: POSITION.y - 12., width: 8., height: 52. },
|
||||||
|
];
|
||||||
|
let physical = Size::new(
|
||||||
|
((PAN.x + (POSITION.x + width + 70.) * zoom) * dpi).ceil() as u32,
|
||||||
|
((PAN.y + (POSITION.y + 90.) * zoom) * dpi).ceil() as u32,
|
||||||
|
);
|
||||||
|
let logical = Size::new(physical.width as f32 / dpi, physical.height as f32 / dpi);
|
||||||
|
Self { logical, physical, dpi, zoom, advance, plate, corridor, blockers }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixels(&self, rect: Rectangle) -> Rectangle {
|
||||||
|
Rectangle {
|
||||||
|
x: (PAN.x + rect.x * self.zoom) * self.dpi,
|
||||||
|
y: (PAN.y + rect.y * self.zoom) * self.dpi,
|
||||||
|
width: rect.width * self.zoom * self.dpi,
|
||||||
|
height: rect.height * self.zoom * self.dpi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grow(rect: Rectangle, x: f32, y: f32) -> Rectangle {
|
||||||
|
Rectangle { x: rect.x - x, y: rect.y - y, width: rect.width + 2. * x, height: rect.height + 2. * y }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
renderer: &mut iced::Renderer,
|
||||||
|
surface: &Surface,
|
||||||
|
label: &Label,
|
||||||
|
session: &RenderSession,
|
||||||
|
style: Style,
|
||||||
|
pass: Pass,
|
||||||
|
pixel_shift: f32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
renderer.reset(Rectangle::with_size(surface.logical));
|
||||||
|
let mut frame = canvas::Frame::new(renderer, surface.logical);
|
||||||
|
// Same order as actual scenario Frame: camera translation, then uniform scale.
|
||||||
|
// The extra translation is only the independent integer-pixel shift oracle.
|
||||||
|
frame.translate(Vector::new(PAN.x + pixel_shift / surface.dpi, PAN.y));
|
||||||
|
frame.scale(surface.zoom);
|
||||||
|
if matches!(pass, Pass::Decoration | Pass::Composite) {
|
||||||
|
frame.fill_rectangle(surface.plate.position(), surface.plate.size(), Color::from_rgb8(255, 230, 120));
|
||||||
|
// Backplate precedes activation-like blockers; text remains foreground.
|
||||||
|
// These are probes, not imported Scene activations or a corridor planner.
|
||||||
|
for blocker in surface.blockers {
|
||||||
|
frame.fill_rectangle(blocker.position(), blocker.size(), Color::from_rgb8(0, 40, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matches!(pass, Pass::Decoration) {
|
||||||
|
let text = if matches!(pass, Pass::ManualText) {
|
||||||
|
// Independently construct the documented paint payload. Both paths
|
||||||
|
// use actual Frame::fill_text; no alternate renderer is involved.
|
||||||
|
canvas::Text {
|
||||||
|
content: label.displayed(session, surface.zoom).unwrap().to_owned(),
|
||||||
|
position: POSITION,
|
||||||
|
color: Color::BLACK,
|
||||||
|
size: style.size,
|
||||||
|
line_height: style.line_height,
|
||||||
|
font: style.font,
|
||||||
|
shaping: style.shaping,
|
||||||
|
max_width: f32::INFINITY,
|
||||||
|
..canvas::Text::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label.canvas_text(session, surface.zoom, POSITION, Color::BLACK).unwrap()
|
||||||
|
};
|
||||||
|
frame.fill_text(text);
|
||||||
|
}
|
||||||
|
renderer.draw_geometry(frame.into_geometry());
|
||||||
|
let pixels = Headless::screenshot(renderer, surface.physical, surface.dpi, Color::WHITE);
|
||||||
|
assert_eq!(pixels.len(), surface.physical.width as usize * surface.physical.height as usize * 4);
|
||||||
|
pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
fn differs(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
a.iter().zip(b).any(|(&a, &b)| a.abs_diff(b) > PIXEL_DELTA)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inside(rect: Rectangle, x: usize, y: usize) -> bool {
|
||||||
|
let x = x as f32 + 0.5;
|
||||||
|
let y = y as f32 + 0.5;
|
||||||
|
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
struct Ink {
|
||||||
|
count: usize,
|
||||||
|
bounds: Option<(usize, usize, usize, usize)>,
|
||||||
|
outside_advance: usize,
|
||||||
|
outside_advance_plus_one_pixel: usize,
|
||||||
|
outside_plate: usize,
|
||||||
|
outside_plate_plus_one_pixel: usize,
|
||||||
|
outside_corridor_x_plus_one_pixel: usize,
|
||||||
|
touches_viewport: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ink(pixels: &[u8], surface: &Surface) -> Ink {
|
||||||
|
let advance = surface.pixels(surface.advance);
|
||||||
|
let plate = surface.pixels(surface.plate);
|
||||||
|
let corridor = surface.pixels(surface.corridor);
|
||||||
|
let mut out = Ink::default();
|
||||||
|
for (i, rgba) in pixels.chunks_exact(4).enumerate() {
|
||||||
|
if !differs(rgba, &[255, 255, 255, 255]) { continue; }
|
||||||
|
let (x, y) = (i % surface.physical.width as usize, i / surface.physical.width as usize);
|
||||||
|
out.count += 1;
|
||||||
|
out.bounds = Some(match out.bounds {
|
||||||
|
None => (x, y, x + 1, y + 1),
|
||||||
|
Some((l, t, r, b)) => (l.min(x), t.min(y), r.max(x + 1), b.max(y + 1)),
|
||||||
|
});
|
||||||
|
out.outside_advance += usize::from(!inside(advance, x, y));
|
||||||
|
out.outside_advance_plus_one_pixel += usize::from(!inside(grow(advance, 1., 1.), x, y));
|
||||||
|
out.outside_plate += usize::from(!inside(plate, x, y));
|
||||||
|
out.outside_plate_plus_one_pixel += usize::from(!inside(grow(plate, 1., 1.), x, y));
|
||||||
|
let px = x as f32 + 0.5;
|
||||||
|
out.outside_corridor_x_plus_one_pixel += usize::from(px < corridor.x - 1. || px >= corridor.x + corridor.width + 1.);
|
||||||
|
out.touches_viewport += usize::from(x == 0 || y == 0 || x + 1 == surface.physical.width as usize || y + 1 == surface.physical.height as usize);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Independent inspection of the actual shaped glyphs is ONLY a coverage gate:
|
||||||
|
// metrics from this buffer do not serve as the painted-ink oracle.
|
||||||
|
fn font_coverage(displayed: &str, style: Style, zoom: f32) -> (usize, usize, bool, BTreeSet<String>) {
|
||||||
|
let size = Pixels(style.size.0 * zoom);
|
||||||
|
let line_height = match style.line_height {
|
||||||
|
LineHeight::Absolute(value) => value.0 * zoom,
|
||||||
|
LineHeight::Relative(value) => value * size.0,
|
||||||
|
};
|
||||||
|
let mut fonts = graphics_text::font_system().write().unwrap();
|
||||||
|
let mut shapes = graphics_text::Cache::new();
|
||||||
|
let (_, entry) = shapes.allocate(fonts.raw(), graphics_text::cache::Key {
|
||||||
|
content: displayed, size: size.0, line_height, font: style.font,
|
||||||
|
bounds: Size::INFINITE, shaping: style.shaping, align_x: Alignment::Default,
|
||||||
|
});
|
||||||
|
let (mut glyphs, mut missing, mut italic) = (0, 0, false);
|
||||||
|
let mut faces = BTreeSet::new();
|
||||||
|
for run in entry.buffer.layout_runs() {
|
||||||
|
for glyph in run.glyphs {
|
||||||
|
glyphs += 1;
|
||||||
|
missing += usize::from(glyph.glyph_id == 0);
|
||||||
|
if let Some(face) = fonts.raw().db().face(glyph.font_id) {
|
||||||
|
italic |= face.style != graphics_text::cosmic_text::fontdb::Style::Normal;
|
||||||
|
faces.insert(format!("{:?} {:?} {:?}", glyph.font_id, face.families, face.style));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(glyphs, missing, italic, faces)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_pixels(dir: &Path, name: &str, pixels: &[u8], size: Size<u32>) {
|
||||||
|
fs::write(dir.join(format!("{name}.rgba")), pixels).unwrap();
|
||||||
|
// Standard portable pixmap for immediate image inspection, no image crate.
|
||||||
|
let mut ppm = format!("P6\n{} {}\n255\n", size.width, size.height).into_bytes();
|
||||||
|
for pixel in pixels.chunks_exact(4) { ppm.extend_from_slice(&pixel[..3]); }
|
||||||
|
fs::write(dir.join(format!("{name}.ppm")), ppm).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate(renderer: &mut iced::Renderer, session: &RenderSession, case: &Case, zoom: f32, dpi: f32, width: f32, dir: &Path) -> (usize, usize, bool) {
|
||||||
|
fs::create_dir(dir).unwrap();
|
||||||
|
let mut cache = Cache::default();
|
||||||
|
let label = cache.resolve(session, case.source, width, zoom, case.style).unwrap();
|
||||||
|
let displayed = label.displayed(session, zoom).unwrap();
|
||||||
|
let retained = label.retained_source(session, zoom).unwrap();
|
||||||
|
let surface = Surface::new(&label, session, zoom, dpi, width);
|
||||||
|
let text = render(renderer, &surface, &label, session, case.style, Pass::Text, 0.);
|
||||||
|
let manual = render(renderer, &surface, &label, session, case.style, Pass::ManualText, 0.);
|
||||||
|
let moved = render(renderer, &surface, &label, session, case.style, Pass::Text, 16.);
|
||||||
|
let decoration = render(renderer, &surface, &label, session, case.style, Pass::Decoration, 0.);
|
||||||
|
let composite = render(renderer, &surface, &label, session, case.style, Pass::Composite, 0.);
|
||||||
|
let measured_ink = ink(&text, &surface);
|
||||||
|
let (glyphs, missing, italic, faces) = font_coverage(displayed, case.style, zoom);
|
||||||
|
let payload_mismatch = text.chunks_exact(4).zip(manual.chunks_exact(4)).filter(|(a, b)| differs(a, b)).count();
|
||||||
|
let mut translation_mismatch = 0;
|
||||||
|
let mut blocker_changed = 0;
|
||||||
|
let mut blocker_samples = 0;
|
||||||
|
for (i, rgba) in moved.chunks_exact(4).enumerate() {
|
||||||
|
let x = i % surface.physical.width as usize;
|
||||||
|
let expected = if x >= 16 { &text[(i - 16) * 4..(i - 16) * 4 + 4] } else { &[255, 255, 255, 255] };
|
||||||
|
translation_mismatch += usize::from(differs(rgba, expected));
|
||||||
|
}
|
||||||
|
for (before, after) in decoration.chunks_exact(4).zip(composite.chunks_exact(4)) {
|
||||||
|
// Sample actual opaque blue blocker interiors, not a guessed screen box.
|
||||||
|
if before[0] < 8 && before[1] < 60 && before[2] > 245 {
|
||||||
|
blocker_samples += 1;
|
||||||
|
blocker_changed += usize::from(differs(before, after));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source_expectation = if width > 500. {
|
||||||
|
displayed == case.source && retained == (0..case.source.len())
|
||||||
|
} else {
|
||||||
|
displayed.ends_with('…') && retained.end < case.source.len() && retained.end > 0
|
||||||
|
};
|
||||||
|
// Infrastructure/contract failures differ from geometric candidate findings.
|
||||||
|
let invalid = usize::from(!source_expectation)
|
||||||
|
+ usize::from(glyphs == 0 || missing > 0 || (case.require_italic && !italic))
|
||||||
|
+ usize::from(measured_ink.count == 0 || measured_ink.touches_viewport > 0)
|
||||||
|
+ usize::from(payload_mismatch > 0 || translation_mismatch > 0 || blocker_samples == 0);
|
||||||
|
let finding = usize::from(measured_ink.outside_plate_plus_one_pixel > 0)
|
||||||
|
+ usize::from(measured_ink.outside_corridor_x_plus_one_pixel > 0)
|
||||||
|
+ usize::from(blocker_changed > 0);
|
||||||
|
let mut report = format!(
|
||||||
|
"source={:?}\ndisplayed={displayed:?}\nretained={retained:?}\nstyle={:?}\nzoom={zoom}\ndpi={dpi}\nwidth={width}\nphysical={:?}\nadvance_world={:?}\nplate_world={:?}\nblockers_world={:?}\nink={measured_ink:#?}\nglyphs={glyphs}\nmissing_glyphs={missing}\nresolved_italic={italic}\npayload_mismatch_pixels={payload_mismatch}\ntranslation_mismatch_pixels={translation_mismatch}\nblocker_samples={blocker_samples}\nblocker_changed_pixels={blocker_changed}\ninvalid_checks={invalid}\ncandidate_findings={finding}\n",
|
||||||
|
case.source, case.style, surface.physical, surface.advance, surface.plate, surface.blockers,
|
||||||
|
);
|
||||||
|
for face in faces { writeln!(&mut report, "resolved_face={face}").unwrap(); }
|
||||||
|
fs::write(dir.join("metrics.txt"), report).unwrap();
|
||||||
|
for (name, pixels) in [("text", &text), ("decoration", &decoration), ("composite", &composite)] {
|
||||||
|
save_pixels(dir, name, pixels, surface.physical);
|
||||||
|
}
|
||||||
|
if payload_mismatch > 0 { save_pixels(dir, "manual-control", &manual, surface.physical); }
|
||||||
|
if translation_mismatch > 0 { save_pixels(dir, "shift-control", &moved, surface.physical); }
|
||||||
|
(invalid, finding, measured_ink.outside_advance > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<_> = std::env::args_os().collect();
|
||||||
|
assert_eq!(args.len(), 2, "usage: scenario-label-wgpu.exe NEW_OUTPUT_DIRECTORY");
|
||||||
|
let out = Path::new(&args[1]);
|
||||||
|
fs::create_dir(out).expect("output directory must be new; preserve prior runs");
|
||||||
|
// Initialize the system font database before the new renderer. No fonts are
|
||||||
|
// loaded after this point, through all screenshots and coverage inspection.
|
||||||
|
let version = graphics_text::font_system().read().unwrap().version();
|
||||||
|
let mut renderer = block_on(<iced::Renderer as Headless>::new(Font::DEFAULT, Pixels(16.), Some("wgpu")))
|
||||||
|
.expect("WGPU adapter/device unavailable: infrastructure failure, never a skipped pass");
|
||||||
|
assert_eq!(Headless::name(&renderer), "wgpu");
|
||||||
|
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||||
|
let mut summary = format!("font_version={version:?}\nrenderer=wgpu\nchannel_delta_threshold={PIXEL_DELTA}\nimported_module=crates/oa-desktop/src/behavior/scenario_label.rs\n");
|
||||||
|
let (mut cases_run, mut invalid, mut findings, mut advance_spill_cases) = (0, 0, 0, 0);
|
||||||
|
for case in cases() {
|
||||||
|
for (zoom, dpi) in [(0.65, 1.), (1., 1.), (1.75, 1.), (1., 1.5)] {
|
||||||
|
for width in [160., 760.] {
|
||||||
|
let name = format!("{}-z{zoom}-dpi{dpi}-w{width}", case.name);
|
||||||
|
let dir = out.join(&name);
|
||||||
|
let (bad, found, advance_spill) = evaluate(&mut renderer, &session, &case, zoom, dpi, width, &dir);
|
||||||
|
// Summary is only an index; full numeric ink fields are per case.
|
||||||
|
advance_spill_cases += usize::from(advance_spill);
|
||||||
|
writeln!(&mut summary, "{name}: invalid={bad} findings={found}").unwrap();
|
||||||
|
invalid += bad;
|
||||||
|
findings += found;
|
||||||
|
cases_run += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(cases_run, 32);
|
||||||
|
writeln!(&mut summary, "cases={cases_run}\ninvalid_checks={invalid}\ncandidate_findings={findings}\nadvance_spill_cases={advance_spill_cases}\n").unwrap();
|
||||||
|
summary.push_str("Advance spill is measured evidence, not automatically a failure. Candidate findings reject this probe's padding/corridor assumptions. This is not Scene/native acceptance.\n");
|
||||||
|
fs::write(out.join("summary.txt"), &summary).unwrap();
|
||||||
|
print!("{summary}");
|
||||||
|
if invalid > 0 { std::process::exit(3); }
|
||||||
|
if findings > 0 { std::process::exit(2); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user