From 9b56a337f8db2c3f63e33e06468cb8de2b93fbf1 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 18:35:52 -0700 Subject: [PATCH] Prepare measured scenario labels and clear corridor evaluators --- .../oa-desktop/src/behavior/scenario_label.rs | 429 +++++++++++++++ .../src/behavior/scenario_label/corridor.rs | 189 +++++++ .../src/behavior/scenario_label/tests.rs | 487 ++++++++++++++++++ .../.gitattributes | 3 + .../README.md | 147 ++++++ .../attempt1/manifest.json | 17 + .../attempt1/scenario_label.rs | 206 ++++++++ .../attempt1/tests.rs | 120 +++++ .../attempt2/README.md | 87 ++++ .../attempt2/manifest.json | 22 + .../attempt2/scenario_label.rs | 271 ++++++++++ .../attempt2/tests.rs | 200 +++++++ .../attempt3/corridor.rs | 154 ++++++ .../attempt3/manifest.json | 17 + .../attempt3/scenario_label.rs | 281 ++++++++++ .../attempt3/tests.rs | 271 ++++++++++ .../run-font-version-test.ps1 | 50 ++ .../standalone.rs | 5 + 18 files changed, 2956 insertions(+) create mode 100644 crates/oa-desktop/src/behavior/scenario_label.rs create mode 100644 crates/oa-desktop/src/behavior/scenario_label/corridor.rs create mode 100644 crates/oa-desktop/src/behavior/scenario_label/tests.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/.gitattributes create mode 100644 harness/experiments/scenario-label-measurement-20260913/README.md create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt1/manifest.json create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt1/scenario_label.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt1/tests.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt2/README.md create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt2/manifest.json create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt2/scenario_label.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt2/tests.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt3/corridor.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt3/manifest.json create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt3/scenario_label.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/attempt3/tests.rs create mode 100644 harness/experiments/scenario-label-measurement-20260913/run-font-version-test.ps1 create mode 100644 harness/experiments/scenario-label-measurement-20260913/standalone.rs diff --git a/crates/oa-desktop/src/behavior/scenario_label.rs b/crates/oa-desktop/src/behavior/scenario_label.rs new file mode 100644 index 00000000..5e7fe6a3 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_label.rs @@ -0,0 +1,429 @@ +//! Unwired single-line scenario label experiment. No renderer or schema is changed. +//! +//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale +//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache. +//! This adapter uses that same allocator/font system/attributes, not a character +//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite. +//! Experimental parity requires settled fonts and a fresh renderer/cache after +//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely +//! remeasuring here cannot repair its cached glyph buffers after dynamic loading. +//! A RenderSession is caller attestation of that lifecycle, not its enforcement. +//! No fonts may be loaded between measurement, guarded access and actual draw. +//! +//! Integration seam: after lifecycle/activation endpoint alignment, a caller +//! supplies a usable text width in world units and retains one cache per live +//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents +//! consume the returned Label. Position/color do not invalidate measurement. +//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy. +//! It measures layout advances, not raster ink; overhang/clip/activation-border +//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px +//! renderer is deliberately untouched for a later baseline comparison. +use iced::advanced::{ + graphics::text as graphics_text, + text::{Alignment, LineHeight, Shaping}, +}; +use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text}; +use std::{collections::VecDeque, ops::Range, sync::Arc}; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_SOURCE_BYTES: usize = 64 * 1024; +const MAX_GRAPHEMES: usize = 4096; +const MAX_ENTRIES: usize = 2; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Style { + pub font: Font, + pub size: Pixels, + pub line_height: LineHeight, + pub shaping: Shaping, +} + +impl Default for Style { + fn default() -> Self { + let text = Text::default(); + Self { + font: text.font, + size: Pixels(12.), + line_height: text.line_height, + shaping: text.shaping, + } + } +} + +impl Style { + fn scaled(self, zoom: f32) -> (f32, f32) { + let size = Pixels(self.size.0 * zoom); + let line_height = match self.line_height { + LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)), + LineHeight::Relative(value) => LineHeight::Relative(value), + }; + (size.0, line_height.to_absolute(size).0) + } +} + +/// Explicit experimental contract: the caller finishes loading fonts and starts +/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed. +/// This module cannot inspect or reset the actual renderer's text cache. +#[derive(Clone, Debug)] +pub(super) struct RenderSession { + version: graphics_text::Version, + identity: Arc<()>, +} + +impl RenderSession { + pub fn after_fonts_settled_and_renderer_reset() -> Result { + let fonts = graphics_text::font_system() + .read() + .map_err(|_| Error::FontSystemUnavailable)?; + Ok(Self { + version: fonts.version(), + identity: Arc::new(()), + }) + } +} + +#[derive(Clone, Debug)] +pub(super) struct Label { + source: String, + displayed: String, + /// Original-label bytes represented before any added ellipsis. Includes + /// collapsed whitespace, but `retains_any` never counts whitespace as text. + retained_source: Range, + /// World-space layout advances, at origin; not glyph-ink containment. + bounds: Rectangle, + style: Style, + measured_session: RenderSession, + measured_zoom: f32, +} + +impl Label { + fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> { + if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) { + return Err(Error::StaleRenderSession); + } + if !zoom.is_finite() || zoom != self.measured_zoom { + return Err(Error::StaleZoom); + } + let fonts = graphics_text::font_system() + .read() + .map_err(|_| Error::FontSystemUnavailable)?; + if fonts.version() != self.measured_session.version || fonts.version() != session.version { + return Err(Error::FontSetChanged); + } + Ok(()) + } + + pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> { + self.check(session, zoom)?; + Ok(&self.displayed) + } + + pub fn retained_source( + &self, + session: &RenderSession, + zoom: f32, + ) -> Result, Error> { + self.check(session, zoom)?; + Ok(self.retained_source.clone()) + } + + pub fn retains_any( + &self, + session: &RenderSession, + zoom: f32, + field: Range, + ) -> Result { + self.check(session, zoom)?; + if field.start > field.end || self.source.get(field.clone()).is_none() { + return Ok(false); + } + let end = field.end.min(self.retained_source.end); + Ok(field.start < end + && self + .source + .get(field.start..end) + .is_some_and(|text| text.chars().any(|c| !c.is_whitespace()))) + } + + /// Validate finite world position and rectangle endpoints only. Camera + /// transforms and raster bounds remain a separate integration contract. + pub fn bounds_at( + &self, + session: &RenderSession, + zoom: f32, + position: Point, + ) -> Result { + self.check(session, zoom)?; + translated_bounds(position, self.bounds.size()) + } + + /// Paint under the same positive uniform zoom used for `Cache::resolve`. + /// No per-label wrapping or implicit text maximum is introduced. + /// The returned Text must be consumed immediately in this session/zoom; + /// retaining raw Text past another font load/zoom change bypasses the guard. + pub fn canvas_text( + &self, + session: &RenderSession, + zoom: f32, + position: Point, + color: Color, + ) -> Result { + self.bounds_at(session, zoom, position)?; + Ok(Text { + content: self.displayed.clone(), + position, + color, + size: self.style.size, + line_height: self.style.line_height, + font: self.style.font, + shaping: self.style.shaping, + max_width: f32::INFINITY, + ..Text::default() + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Error { + InvalidMetrics, + SourceBudget, + GraphemeBudget, + FontSystemUnavailable, + InvalidMeasurement, + InvalidPosition, + StaleZoom, + StaleRenderSession, + FontSetChanged, +} + +struct Entry { + source: String, + style: Style, + zoom: f32, + width: f32, + label: Arc