From 7548139861be95ce6f76380c3337c974c0325792 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 00:34:01 -0400 Subject: [PATCH] Add a fixed-point number for layout to decide on Layout reaches one place by more than one route -- a box composed down the chain, and the same box summed from what its children asked for -- and the two land a few bits apart in floats. Where that decides something structural rather than something positional, a warm tree disagrees with a cold one: `5ed9e87` is the instance, and its margin is a patch over the representation rather than a fix to it. `Fixed` is a count of `1 / 2^SHIFT`s in an `i32`. Adding and subtracting are exact, a multiply rounds once back onto the same steps, and two routes that come within half a step land on the same number -- so the comparisons downstream can ask for equality rather than for nearness. `Px = Fixed<6>` and `Rel = Fixed<24>`: a sixty-fourth of a pixel is finer than a display and still exact in `f32` up to 262,144 px, and twenty-four bits of fraction matches `f32` at a half, beats it above one where anchors sit, and leaves +/-128 of range to sum relative children in. Nothing uses it yet. The arithmetic saturates rather than wrapping, because a clamped coordinate keeps the ordering a wrapped one inverts, and the ends are what an unbounded interval will be written with. Checked: fmt, clippy, 99 tests including ten for this type -- the round trip through `f32`, halves rounding away from zero either side, saturation at both ends, and 20,000 additions landing exactly where the count says. Co-Authored-By: Claude Opus 5 --- core/src/fixed.rs | 349 ++++++++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 2 + 2 files changed, 351 insertions(+) create mode 100644 core/src/fixed.rs diff --git a/core/src/fixed.rs b/core/src/fixed.rs new file mode 100644 index 0000000..c1df01f --- /dev/null +++ b/core/src/fixed.rs @@ -0,0 +1,349 @@ +use std::{ + fmt::{Debug, Display, Formatter}, + ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}, +}; + +/// A number held as a whole count of `1 / 2^SHIFT`. +/// +/// Layout reaches one place by more than one route -- a box composed down the +/// chain, and the same box summed from what its children asked for -- and has +/// to decide whether the two are the same place. In floats they land a few +/// bits apart, which is a defect wherever the answer changes what is drawn +/// rather than where. Here adding and subtracting are exact and only a +/// multiply or a conversion rounds, back onto the same steps, so two routes +/// that come within half a step land on one number and everything downstream +/// compares for equality instead of for nearness. +/// +/// `SHIFT` is the number of fractional bits, which is what makes the steps +/// divide a whole number: a power of two also converts to `f32` without +/// rounding while the value fits in its mantissa. +#[repr(transparent)] +#[derive( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, bytemuck::Pod, bytemuck::Zeroable, +)] +pub struct Fixed(i32); + +/// A length or a coordinate in pixels, to a sixty-fourth. Finer than anything +/// a display can show, and exact in `f32` up to 262,144 px, which is what lets +/// the same number reach the GPU. +pub type Px = Fixed<6>; + +/// A fraction of a box. Twenty-four bits of it, which matches `f32` around a +/// half and beats it above one -- where anchors actually sit -- and leaves +/// +/-128 of range, enough to sum a hundred children each asking for a whole +/// box. A `leftover` weight is not one of these: it is a share of what is +/// left rather than a fraction of anything, and it sums over a whole list. +pub type Rel = Fixed<24>; + +impl Fixed { + pub const ZERO: Self = Self(0); + pub const ONE: Self = Self::one(); + /// Also what stands in for an unbounded end, since arithmetic saturates + /// here rather than wrapping past it. + pub const MIN: Self = Self(i32::MIN); + pub const MAX: Self = Self(i32::MAX); + + const fn one() -> Self { + assert!(SHIFT < 31, "a Fixed needs a bit for the whole part"); + Self(1 << SHIFT) + } + + pub const fn from_raw(raw: i32) -> Self { + Self(raw) + } + + /// The count of steps, for a caller that needs the representation rather + /// than the number. + pub const fn raw(self) -> i32 { + self.0 + } + + pub const fn from_int(v: i32) -> Self { + Self(v.saturating_mul(Self::one().0)) + } + + /// Rounds to the nearest step, and saturates rather than wrapping. A NaN + /// has no nearest step and becomes zero, which is a caller's mistake + /// rather than a value worth carrying. + pub fn from_f32(v: f32) -> Self { + debug_assert!(!v.is_nan(), "a NaN has no place on the grid"); + // Float-to-int casts saturate and send NaN to zero, which is the + // behaviour wanted at both ends. + Self((v * Self::one().0 as f32).round() as i32) + } + + pub fn to_f32(self) -> f32 { + self.0 as f32 / Self::one().0 as f32 + } + + /// The same value on another grid, rounded where the new one is coarser. + pub const fn to_scale(self) -> Fixed { + Fixed(match TO >= SHIFT { + true => narrow((self.0 as i64) << (TO - SHIFT)), + false => narrow(shift_round(self.0 as i64, SHIFT - TO)), + }) + } + + pub const fn add(self, rhs: Self) -> Self { + Self(self.0.saturating_add(rhs.0)) + } + + pub const fn sub(self, rhs: Self) -> Self { + Self(self.0.saturating_sub(rhs.0)) + } + + pub const fn neg(self) -> Self { + Self(self.0.saturating_neg()) + } + + /// Scaled by a number on any grid, which is how a length takes a fraction + /// of itself and keeps being a length: the product is measured in the + /// receiver's steps. + pub const fn mul(self, by: Fixed) -> Self { + Self(narrow(shift_round(self.0 as i64 * by.0 as i64, BY))) + } + + /// Divided by a number on any grid. A zero divisor is a caller bug -- a + /// box of no length has no fraction of itself -- and saturates so that a + /// release build lays out something absurd rather than dying. + pub const fn div(self, by: Fixed) -> Self { + debug_assert!(by.0 != 0, "dividing by a length of zero"); + if by.0 == 0 { + return match self.0 < 0 { + true => Self::MIN, + false => Self::MAX, + }; + } + Self(narrow(div_round((self.0 as i64) << BY, by.0 as i64))) + } + + /// `from` and `to` a fraction of the way apart, the fraction being the + /// receiver -- the argument order [`crate::util::LerpUtil`] already uses. + pub const fn lerp(self, from: Fixed, to: Fixed) -> Fixed { + from.add(to.sub(from).mul(self)) + } + + pub const fn min(self, other: Self) -> Self { + match self.0 < other.0 { + true => self, + false => other, + } + } + + pub const fn max(self, other: Self) -> Self { + match self.0 > other.0 { + true => self, + false => other, + } + } + + pub const fn abs(self) -> Self { + Self(self.0.saturating_abs()) + } + + pub const fn clamp(self, lo: Self, hi: Self) -> Self { + debug_assert!(lo.0 <= hi.0, "an empty clamp has no answer"); + self.max(lo).min(hi) + } + + /// The next value along, for an interval that must not admit its own + /// boundary. The step is the whole gap, so there is nothing to exclude + /// between this and the boundary itself. + pub const fn next_up(self) -> Self { + Self(self.0.saturating_add(1)) + } + + pub const fn next_down(self) -> Self { + Self(self.0.saturating_sub(1)) + } +} + +/// Back to a single step, rounding halves away from zero so that a value and +/// its negation round to the same distance. +const fn shift_round(v: i64, bits: u32) -> i64 { + let half = (1i64 << bits) >> 1; + match v < 0 { + true => -((-v + half) >> bits), + false => (v + half) >> bits, + } +} + +const fn div_round(num: i64, den: i64) -> i64 { + let (q, rem) = (num / den, num % den); + match rem.unsigned_abs() * 2 >= den.unsigned_abs() { + true => match (num < 0) == (den < 0) { + true => q + 1, + false => q - 1, + }, + false => q, + } +} + +const fn narrow(v: i64) -> i32 { + if v > i32::MAX as i64 { + return i32::MAX; + } + if v < i32::MIN as i64 { + return i32::MIN; + } + v as i32 +} + +const impl Add for Fixed { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + Fixed::add(self, rhs) + } +} + +const impl Sub for Fixed { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + Fixed::sub(self, rhs) + } +} + +const impl Neg for Fixed { + type Output = Self; + + fn neg(self) -> Self { + Fixed::neg(self) + } +} + +const impl AddAssign for Fixed { + fn add_assign(&mut self, rhs: Self) { + *self = Fixed::add(*self, rhs); + } +} + +const impl SubAssign for Fixed { + fn sub_assign(&mut self, rhs: Self) { + *self = Fixed::sub(*self, rhs); + } +} + +const impl Mul> for Fixed { + type Output = Self; + + fn mul(self, rhs: Fixed) -> Self { + Fixed::mul(self, rhs) + } +} + +const impl Div> for Fixed { + type Output = Self; + + fn div(self, rhs: Fixed) -> Self { + Fixed::div(self, rhs) + } +} + +impl Display for Fixed { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.to_f32(), f) + } +} + +/// Prints the number rather than the count of steps: a failing layout test +/// reports boxes, and `1126` is not a height anybody can read. +impl Debug for Fixed { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.to_f32(), f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sum_of_steps_does_not_drift() { + let mut at = Px::ZERO; + for _ in 0..20_000 { + at += Px::from_raw(3); + } + assert_eq!(at, Px::from_raw(60_000)); + for _ in 0..20_000 { + at -= Px::from_raw(3); + } + assert_eq!(at, Px::ZERO); + } + + #[test] + fn a_pixel_survives_the_trip_through_f32() { + for raw in [0, 1, -1, 64, -1000, 16_777_215, -16_777_215] { + let px = Px::from_raw(raw); + assert_eq!(Px::from_f32(px.to_f32()), px); + } + } + + #[test] + fn a_fraction_of_a_length_is_a_length() { + let half = Px::from_int(100) * Rel::from_f32(0.5); + assert_eq!(half, Px::from_int(50)); + assert_eq!(Px::from_int(100) * Rel::ONE, Px::from_int(100)); + assert_eq!(Px::from_int(100) * Rel::ZERO, Px::ZERO); + } + + #[test] + fn halves_round_away_from_zero_either_side() { + // A sixty-fourth and a half of one, which has no step of its own. + let one_and_a_half = Rel::from_f32(1.5) / Rel::from_int(64); + assert_eq!(Px::ONE * one_and_a_half, Px::from_raw(2)); + assert_eq!(Px::ONE.neg() * one_and_a_half, Px::from_raw(-2)); + } + + #[test] + fn dividing_by_a_fraction_undoes_multiplying_by_it() { + let third = Rel::ONE / Rel::from_int(3); + let len = Px::from_int(300); + assert_eq!(len * third / third, len); + assert_eq!(Px::from_int(100) / Rel::from_f32(0.5), Px::from_int(200)); + } + + #[test] + fn arithmetic_saturates_rather_than_wrapping() { + assert_eq!(Px::MAX + Px::ONE, Px::MAX); + assert_eq!(Px::MIN - Px::ONE, Px::MIN); + assert_eq!(Px::from_f32(1e12), Px::MAX); + assert_eq!(Px::from_f32(-1e12), Px::MIN); + assert_eq!(Px::from_int(i32::MAX), Px::MAX); + } + + #[test] + fn a_coarser_grid_rounds_and_a_finer_one_does_not() { + // A third, which neither grid holds exactly. + let third = Rel::ONE / Rel::from_int(3); + assert_eq!(third.to_scale::<6>(), Fixed::<6>::from_raw(21)); + assert_eq!( + Px::from_raw(21).to_scale::<24>().to_scale::<6>(), + Px::from_raw(21) + ); + } + + #[test] + fn lerp_takes_the_fraction_as_the_receiver() { + let (from, to) = (Px::from_int(10), Px::from_int(20)); + assert_eq!(Rel::ZERO.lerp(from, to), from); + assert_eq!(Rel::ONE.lerp(from, to), to); + assert_eq!(Rel::from_f32(0.5).lerp(from, to), Px::from_int(15)); + assert_eq!(Rel::from_f32(0.5).lerp(to, from), Px::from_int(15)); + } + + #[test] + fn nothing_sits_between_a_value_and_the_next_one() { + let at = Px::from_int(3); + assert_eq!(at.next_up().next_down(), at); + assert_eq!(at.next_up().raw() - at.raw(), 1); + assert!(at.next_down() < at && at < at.next_up()); + } + + #[test] + fn it_prints_the_number_rather_than_the_steps() { + assert_eq!(format!("{:?}", Px::from_f32(17.59375)), "17.59375"); + assert_eq!(format!("{}", Px::from_int(-2)), "-2"); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 6c40607..22dd198 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -15,6 +15,7 @@ pub mod layout_diagnostics; mod attr; mod event; +mod fixed; mod num; mod orientation; mod primitive; @@ -26,6 +27,7 @@ pub mod util; pub use attr::*; pub use event::*; +pub use fixed::*; pub use num::*; pub use orientation::*; pub use primitive::*;