Skip to main content

astrelis_core/
geometry.rs

1//! Type-safe coordinate system with explicit Logical/Physical coordinate spaces.
2//!
3//! This module provides compile-time safety for coordinate handling, preventing
4//! accidental mixing of logical (DPI-independent) and physical (pixel) coordinates.
5//!
6//! # Overview
7//!
8//! - [`Logical`] coordinates are DPI-independent (e.g., 100x100 logical pixels)
9//! - [`Physical`] coordinates are actual screen pixels (e.g., 200x200 on a 2x DPI display)
10//! - [`ScaleFactor`] represents the DPI scale factor for conversions
11//!
12//! # Example
13//!
14//! ```rust
15//! use astrelis_core::geometry::{LogicalSize, PhysicalSize, ScaleFactor};
16//!
17//! let logical = LogicalSize::new(800.0, 600.0);
18//! let scale = ScaleFactor(2.0);
19//! let physical: PhysicalSize<u32> = logical.to_physical(scale);
20//! assert_eq!(physical.width, 1600);
21//! assert_eq!(physical.height, 1200);
22//! ```
23
24use std::marker::PhantomData;
25use std::ops::{Add, Mul, Sub};
26
27// =============================================================================
28// Coordinate Space Markers
29// =============================================================================
30
31/// Marker trait for coordinate spaces.
32///
33/// This trait is sealed and cannot be implemented outside this module.
34pub trait CoordinateSpace: Copy + Clone + private::Sealed {}
35
36mod private {
37    pub trait Sealed {}
38    impl Sealed for super::Logical {}
39    impl Sealed for super::Physical {}
40}
41
42/// Marker type for logical (DPI-independent) coordinates.
43///
44/// Logical coordinates remain constant regardless of the display's DPI.
45/// For example, a 100x100 logical size window will appear the same physical
46/// size on any display, but the actual pixel count will vary.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
48pub struct Logical;
49impl CoordinateSpace for Logical {}
50
51/// Marker type for physical (pixel) coordinates.
52///
53/// Physical coordinates represent actual screen pixels.
54/// A 100x100 physical size means exactly 100x100 pixels on screen.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
56pub struct Physical;
57impl CoordinateSpace for Physical {}
58
59// =============================================================================
60// Scale Factor
61// =============================================================================
62
63/// Scale factor for converting between logical and physical coordinates.
64///
65/// Common values:
66/// - 1.0: Standard DPI (96 DPI on Windows, 72 DPI on macOS historically)
67/// - 2.0: Retina/HiDPI displays
68/// - 1.5, 1.25: Common Windows scaling factors
69///
70/// # Safety
71///
72/// The scale factor must be positive (greater than 0). Methods like `inverse()`
73/// and coordinate conversions will produce infinity or NaN if the scale is 0.
74/// Use `try_new()` or `is_valid()` to validate scale factors from untrusted input.
75///
76/// # Example
77///
78/// ```rust
79/// use astrelis_core::geometry::ScaleFactor;
80///
81/// let scale = ScaleFactor(2.0);
82/// assert_eq!(scale.0, 2.0);
83/// assert_eq!(scale.inverse().0, 0.5);
84///
85/// // Validate scale factors from user input
86/// let scale = ScaleFactor::try_new(2.0).expect("Invalid scale");
87/// ```
88#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
89pub struct ScaleFactor(pub f64);
90
91impl ScaleFactor {
92    /// Create a new scale factor.
93    ///
94    /// # Panics
95    ///
96    /// In debug builds, panics if the scale is not positive (> 0) or is NaN.
97    /// In release builds, invalid values may cause division by zero or NaN propagation.
98    #[inline]
99    pub const fn new(scale: f64) -> Self {
100        // Note: Can't do runtime checks in const fn, but we document the requirement
101        Self(scale)
102    }
103
104    /// Try to create a new scale factor, returning None if invalid.
105    ///
106    /// Returns `None` if:
107    /// - The scale is zero or negative
108    /// - The scale is NaN or infinity
109    ///
110    /// # Example
111    ///
112    /// ```rust
113    /// use astrelis_core::geometry::ScaleFactor;
114    ///
115    /// assert!(ScaleFactor::try_new(2.0).is_some());
116    /// assert!(ScaleFactor::try_new(0.0).is_none());
117    /// assert!(ScaleFactor::try_new(-1.0).is_none());
118    /// assert!(ScaleFactor::try_new(f64::NAN).is_none());
119    /// ```
120    #[inline]
121    pub fn try_new(scale: f64) -> Option<Self> {
122        if scale.is_finite() && scale > 0.0 {
123            Some(Self(scale))
124        } else {
125            None
126        }
127    }
128
129    /// Check if this scale factor is valid (positive and finite).
130    #[inline]
131    pub fn is_valid(self) -> bool {
132        self.0.is_finite() && self.0 > 0.0
133    }
134
135    /// Get the inverse scale factor (1.0 / scale).
136    ///
137    /// # Note
138    ///
139    /// Returns infinity if the scale is 0. Use `is_valid()` to check first
140    /// if the scale factor comes from untrusted input.
141    #[inline]
142    pub fn inverse(self) -> Self {
143        debug_assert!(
144            self.is_valid(),
145            "ScaleFactor::inverse() called on invalid scale factor: {}",
146            self.0
147        );
148        Self(1.0 / self.0)
149    }
150
151    /// Get the scale as f32.
152    #[inline]
153    pub fn as_f32(self) -> f32 {
154        self.0 as f32
155    }
156
157    /// Get the scale as f64.
158    #[inline]
159    pub fn as_f64(self) -> f64 {
160        self.0
161    }
162}
163
164impl Default for ScaleFactor {
165    fn default() -> Self {
166        Self(1.0)
167    }
168}
169
170impl From<f64> for ScaleFactor {
171    fn from(scale: f64) -> Self {
172        Self(scale)
173    }
174}
175
176impl From<f32> for ScaleFactor {
177    fn from(scale: f32) -> Self {
178        Self(scale as f64)
179    }
180}
181
182impl From<ScaleFactor> for f64 {
183    fn from(scale: ScaleFactor) -> Self {
184        scale.0
185    }
186}
187
188impl From<ScaleFactor> for f32 {
189    fn from(scale: ScaleFactor) -> Self {
190        scale.0 as f32
191    }
192}
193
194// =============================================================================
195// Size2D
196// =============================================================================
197
198/// A 2D size with a coordinate space marker.
199///
200/// Use the type aliases [`LogicalSize`] and [`PhysicalSize`] for convenience.
201///
202/// # Example
203///
204/// ```rust
205/// use astrelis_core::geometry::{LogicalSize, PhysicalSize, ScaleFactor};
206///
207/// let logical = LogicalSize::new(800.0_f32, 600.0);
208/// let physical = logical.to_physical(ScaleFactor(2.0));
209/// assert_eq!(physical.width, 1600);
210/// ```
211#[derive(Debug, Clone, Copy)]
212pub struct Size2D<T, S: CoordinateSpace> {
213    pub width: T,
214    pub height: T,
215    _marker: PhantomData<S>,
216}
217
218impl<T: PartialEq, S: CoordinateSpace> PartialEq for Size2D<T, S> {
219    fn eq(&self, other: &Self) -> bool {
220        self.width == other.width && self.height == other.height
221    }
222}
223
224impl<T: Eq, S: CoordinateSpace> Eq for Size2D<T, S> {}
225
226impl<T: Default, S: CoordinateSpace> Default for Size2D<T, S> {
227    fn default() -> Self {
228        Self {
229            width: T::default(),
230            height: T::default(),
231            _marker: PhantomData,
232        }
233    }
234}
235
236impl<T: std::hash::Hash, S: CoordinateSpace> std::hash::Hash for Size2D<T, S> {
237    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
238        self.width.hash(state);
239        self.height.hash(state);
240    }
241}
242
243impl<T, S: CoordinateSpace> Size2D<T, S> {
244    /// Create a new size.
245    #[inline]
246    pub const fn new(width: T, height: T) -> Self {
247        Self {
248            width,
249            height,
250            _marker: PhantomData,
251        }
252    }
253
254    /// Convert to a tuple (width, height).
255    #[inline]
256    pub fn into_tuple(self) -> (T, T) {
257        (self.width, self.height)
258    }
259
260    /// Create from a tuple (width, height).
261    #[inline]
262    pub fn from_tuple(tuple: (T, T)) -> Self {
263        Self::new(tuple.0, tuple.1)
264    }
265}
266
267impl<T: Copy, S: CoordinateSpace> Size2D<T, S> {
268    /// Get the width.
269    #[inline]
270    pub fn width(&self) -> T {
271        self.width
272    }
273
274    /// Get the height.
275    #[inline]
276    pub fn height(&self) -> T {
277        self.height
278    }
279}
280
281impl<T: Copy, S: CoordinateSpace> Size2D<T, S> {
282    /// Cast to a different numeric type within the same coordinate space.
283    #[inline]
284    pub fn cast<U>(self) -> Size2D<U, S>
285    where
286        T: Into<U>,
287    {
288        Size2D::new(self.width.into(), self.height.into())
289    }
290}
291
292impl<T, S: CoordinateSpace> Size2D<T, S>
293where
294    T: Copy + Into<f64>,
295{
296    /// Cast to f32 type within the same coordinate space.
297    #[inline]
298    pub fn to_f32(self) -> Size2D<f32, S> {
299        Size2D::new(self.width.into() as f32, self.height.into() as f32)
300    }
301
302    /// Cast to f64 type within the same coordinate space.
303    #[inline]
304    pub fn to_f64(self) -> Size2D<f64, S> {
305        Size2D::new(self.width.into(), self.height.into())
306    }
307}
308
309impl<T: Mul<Output = T> + Copy, S: CoordinateSpace> Mul<T> for Size2D<T, S> {
310    type Output = Self;
311
312    fn mul(self, rhs: T) -> Self::Output {
313        Self::new(self.width * rhs, self.height * rhs)
314    }
315}
316
317// Logical size conversions
318impl<T> Size2D<T, Logical>
319where
320    T: Copy + Into<f64>,
321{
322    /// Convert to physical coordinates.
323    #[inline]
324    pub fn to_physical(self, scale: ScaleFactor) -> PhysicalSize<u32> {
325        PhysicalSize::new(
326            (self.width.into() * scale.0).round() as u32,
327            (self.height.into() * scale.0).round() as u32,
328        )
329    }
330
331    /// Convert to physical coordinates as f32.
332    #[inline]
333    pub fn to_physical_f32(self, scale: ScaleFactor) -> PhysicalSize<f32> {
334        PhysicalSize::new(
335            (self.width.into() * scale.0) as f32,
336            (self.height.into() * scale.0) as f32,
337        )
338    }
339
340    /// Convert to physical coordinates as f64.
341    #[inline]
342    pub fn to_physical_f64(self, scale: ScaleFactor) -> PhysicalSize<f64> {
343        PhysicalSize::new(self.width.into() * scale.0, self.height.into() * scale.0)
344    }
345}
346
347// Physical size conversions
348impl<T> Size2D<T, Physical>
349where
350    T: Copy + Into<f64>,
351{
352    /// Convert to logical coordinates.
353    #[inline]
354    pub fn to_logical(self, scale: ScaleFactor) -> LogicalSize<f32> {
355        LogicalSize::new(
356            (self.width.into() / scale.0) as f32,
357            (self.height.into() / scale.0) as f32,
358        )
359    }
360
361    /// Convert to logical coordinates as f64.
362    #[inline]
363    pub fn to_logical_f64(self, scale: ScaleFactor) -> LogicalSize<f64> {
364        LogicalSize::new(self.width.into() / scale.0, self.height.into() / scale.0)
365    }
366}
367
368/// Logical (DPI-independent) size.
369pub type LogicalSize<T> = Size2D<T, Logical>;
370
371/// Physical (pixel) size.
372pub type PhysicalSize<T> = Size2D<T, Physical>;
373
374// =============================================================================
375// Position2D
376// =============================================================================
377
378/// A 2D position with a coordinate space marker.
379///
380/// Use the type aliases [`LogicalPosition`] and [`PhysicalPosition`] for convenience.
381#[derive(Debug, Clone, Copy)]
382pub struct Position2D<T, S: CoordinateSpace> {
383    pub x: T,
384    pub y: T,
385    _marker: PhantomData<S>,
386}
387
388impl<T: PartialEq, S: CoordinateSpace> PartialEq for Position2D<T, S> {
389    fn eq(&self, other: &Self) -> bool {
390        self.x == other.x && self.y == other.y
391    }
392}
393
394impl<T: Eq, S: CoordinateSpace> Eq for Position2D<T, S> {}
395
396impl<T: Default, S: CoordinateSpace> Default for Position2D<T, S> {
397    fn default() -> Self {
398        Self {
399            x: T::default(),
400            y: T::default(),
401            _marker: PhantomData,
402        }
403    }
404}
405
406impl<T: std::hash::Hash, S: CoordinateSpace> std::hash::Hash for Position2D<T, S> {
407    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
408        self.x.hash(state);
409        self.y.hash(state);
410    }
411}
412
413impl<T, S: CoordinateSpace> Position2D<T, S> {
414    /// Create a new position.
415    #[inline]
416    pub const fn new(x: T, y: T) -> Self {
417        Self {
418            x,
419            y,
420            _marker: PhantomData,
421        }
422    }
423
424    /// Origin position (0, 0).
425    #[inline]
426    pub fn origin() -> Self
427    where
428        T: Default,
429    {
430        Self::default()
431    }
432
433    /// Convert to a tuple (x, y).
434    #[inline]
435    pub fn into_tuple(self) -> (T, T) {
436        (self.x, self.y)
437    }
438
439    /// Create from a tuple (x, y).
440    #[inline]
441    pub fn from_tuple(tuple: (T, T)) -> Self {
442        Self::new(tuple.0, tuple.1)
443    }
444}
445
446impl<T: Copy, S: CoordinateSpace> Position2D<T, S> {
447    /// Get the x coordinate.
448    #[inline]
449    pub fn x(&self) -> T {
450        self.x
451    }
452
453    /// Get the y coordinate.
454    #[inline]
455    pub fn y(&self) -> T {
456        self.y
457    }
458}
459
460impl<T, S: CoordinateSpace> Position2D<T, S>
461where
462    T: Copy + Into<f64>,
463{
464    /// Cast to f32 type within the same coordinate space.
465    #[inline]
466    pub fn to_f32(self) -> Position2D<f32, S> {
467        Position2D::new(self.x.into() as f32, self.y.into() as f32)
468    }
469
470    /// Cast to f64 type within the same coordinate space.
471    #[inline]
472    pub fn to_f64(self) -> Position2D<f64, S> {
473        Position2D::new(self.x.into(), self.y.into())
474    }
475}
476
477impl<T: Mul<Output = T> + Copy, S: CoordinateSpace> Mul<T> for Position2D<T, S> {
478    type Output = Self;
479
480    fn mul(self, rhs: T) -> Self::Output {
481        Self::new(self.x * rhs, self.y * rhs)
482    }
483}
484
485impl<T: Add<Output = T>, S: CoordinateSpace> Add for Position2D<T, S> {
486    type Output = Self;
487
488    fn add(self, rhs: Self) -> Self::Output {
489        Self::new(self.x + rhs.x, self.y + rhs.y)
490    }
491}
492
493impl<T: Sub<Output = T>, S: CoordinateSpace> Sub for Position2D<T, S> {
494    type Output = Self;
495
496    fn sub(self, rhs: Self) -> Self::Output {
497        Self::new(self.x - rhs.x, self.y - rhs.y)
498    }
499}
500
501// Logical position conversions
502impl<T> Position2D<T, Logical>
503where
504    T: Copy + Into<f64>,
505{
506    /// Convert to physical coordinates.
507    #[inline]
508    pub fn to_physical(self, scale: ScaleFactor) -> PhysicalPosition<i32> {
509        PhysicalPosition::new(
510            (self.x.into() * scale.0).round() as i32,
511            (self.y.into() * scale.0).round() as i32,
512        )
513    }
514
515    /// Convert to physical coordinates as f32.
516    #[inline]
517    pub fn to_physical_f32(self, scale: ScaleFactor) -> PhysicalPosition<f32> {
518        PhysicalPosition::new(
519            (self.x.into() * scale.0) as f32,
520            (self.y.into() * scale.0) as f32,
521        )
522    }
523
524    /// Convert to physical coordinates as f64.
525    #[inline]
526    pub fn to_physical_f64(self, scale: ScaleFactor) -> PhysicalPosition<f64> {
527        PhysicalPosition::new(self.x.into() * scale.0, self.y.into() * scale.0)
528    }
529}
530
531// Physical position conversions
532impl<T> Position2D<T, Physical>
533where
534    T: Copy + Into<f64>,
535{
536    /// Convert to logical coordinates.
537    #[inline]
538    pub fn to_logical(self, scale: ScaleFactor) -> LogicalPosition<f32> {
539        LogicalPosition::new(
540            (self.x.into() / scale.0) as f32,
541            (self.y.into() / scale.0) as f32,
542        )
543    }
544
545    /// Convert to logical coordinates as f64.
546    #[inline]
547    pub fn to_logical_f64(self, scale: ScaleFactor) -> LogicalPosition<f64> {
548        LogicalPosition::new(self.x.into() / scale.0, self.y.into() / scale.0)
549    }
550}
551
552/// Logical (DPI-independent) position.
553pub type LogicalPosition<T> = Position2D<T, Logical>;
554
555/// Physical (pixel) position.
556pub type PhysicalPosition<T> = Position2D<T, Physical>;
557
558// =============================================================================
559// Rect2D
560// =============================================================================
561
562/// A 2D rectangle with a coordinate space marker.
563///
564/// Use the type aliases [`LogicalRect`] and [`PhysicalRect`] for convenience.
565#[derive(Debug, Clone, Copy)]
566pub struct Rect2D<T, S: CoordinateSpace> {
567    pub x: T,
568    pub y: T,
569    pub width: T,
570    pub height: T,
571    _marker: PhantomData<S>,
572}
573
574impl<T: PartialEq, S: CoordinateSpace> PartialEq for Rect2D<T, S> {
575    fn eq(&self, other: &Self) -> bool {
576        self.x == other.x
577            && self.y == other.y
578            && self.width == other.width
579            && self.height == other.height
580    }
581}
582
583impl<T: Eq, S: CoordinateSpace> Eq for Rect2D<T, S> {}
584
585impl<T: Default, S: CoordinateSpace> Default for Rect2D<T, S> {
586    fn default() -> Self {
587        Self {
588            x: T::default(),
589            y: T::default(),
590            width: T::default(),
591            height: T::default(),
592            _marker: PhantomData,
593        }
594    }
595}
596
597impl<T: std::hash::Hash, S: CoordinateSpace> std::hash::Hash for Rect2D<T, S> {
598    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
599        self.x.hash(state);
600        self.y.hash(state);
601        self.width.hash(state);
602        self.height.hash(state);
603    }
604}
605
606impl<T, S: CoordinateSpace> Rect2D<T, S> {
607    /// Create a new rectangle.
608    #[inline]
609    pub const fn new(x: T, y: T, width: T, height: T) -> Self {
610        Self {
611            x,
612            y,
613            width,
614            height,
615            _marker: PhantomData,
616        }
617    }
618
619    /// Create a rectangle from position and size.
620    #[inline]
621    pub fn from_position_size(position: Position2D<T, S>, size: Size2D<T, S>) -> Self {
622        Self::new(position.x, position.y, size.width, size.height)
623    }
624}
625
626impl<T: Copy, S: CoordinateSpace> Rect2D<T, S> {
627    /// Get the position (x, y) as a Position2D.
628    #[inline]
629    pub fn position(&self) -> Position2D<T, S> {
630        Position2D::new(self.x, self.y)
631    }
632
633    /// Get the size (width, height) as a Size2D.
634    #[inline]
635    pub fn size(&self) -> Size2D<T, S> {
636        Size2D::new(self.width, self.height)
637    }
638
639    /// Get the x coordinate.
640    #[inline]
641    pub fn x(&self) -> T {
642        self.x
643    }
644
645    /// Get the y coordinate.
646    #[inline]
647    pub fn y(&self) -> T {
648        self.y
649    }
650
651    /// Get the width.
652    #[inline]
653    pub fn width(&self) -> T {
654        self.width
655    }
656
657    /// Get the height.
658    #[inline]
659    pub fn height(&self) -> T {
660        self.height
661    }
662}
663
664impl<T, S: CoordinateSpace> Rect2D<T, S>
665where
666    T: Copy + Add<Output = T>,
667{
668    /// Get the right edge (x + width).
669    #[inline]
670    pub fn right(&self) -> T {
671        self.x + self.width
672    }
673
674    /// Get the bottom edge (y + height).
675    #[inline]
676    pub fn bottom(&self) -> T {
677        self.y + self.height
678    }
679}
680
681impl<T, S: CoordinateSpace> Rect2D<T, S>
682where
683    T: Copy + Into<f64>,
684{
685    /// Cast to f32 type within the same coordinate space.
686    #[inline]
687    pub fn to_f32(self) -> Rect2D<f32, S> {
688        Rect2D::new(
689            self.x.into() as f32,
690            self.y.into() as f32,
691            self.width.into() as f32,
692            self.height.into() as f32,
693        )
694    }
695
696    /// Cast to f64 type within the same coordinate space.
697    #[inline]
698    pub fn to_f64(self) -> Rect2D<f64, S> {
699        Rect2D::new(
700            self.x.into(),
701            self.y.into(),
702            self.width.into(),
703            self.height.into(),
704        )
705    }
706}
707
708impl<T, S: CoordinateSpace> Rect2D<T, S>
709where
710    T: Copy + PartialOrd + Add<Output = T>,
711{
712    /// Check if a point is contained within this rectangle.
713    #[inline]
714    pub fn contains(&self, point: Position2D<T, S>) -> bool {
715        point.x >= self.x
716            && point.x < self.x + self.width
717            && point.y >= self.y
718            && point.y < self.y + self.height
719    }
720}
721
722// Logical rect conversions
723impl<T> Rect2D<T, Logical>
724where
725    T: Copy + Into<f64>,
726{
727    /// Convert to physical coordinates.
728    #[inline]
729    pub fn to_physical(self, scale: ScaleFactor) -> PhysicalRect<u32> {
730        PhysicalRect::new(
731            (self.x.into() * scale.0).round() as u32,
732            (self.y.into() * scale.0).round() as u32,
733            (self.width.into() * scale.0).round() as u32,
734            (self.height.into() * scale.0).round() as u32,
735        )
736    }
737
738    /// Convert to physical coordinates as f32.
739    #[inline]
740    pub fn to_physical_f32(self, scale: ScaleFactor) -> PhysicalRect<f32> {
741        PhysicalRect::new(
742            (self.x.into() * scale.0) as f32,
743            (self.y.into() * scale.0) as f32,
744            (self.width.into() * scale.0) as f32,
745            (self.height.into() * scale.0) as f32,
746        )
747    }
748}
749
750// Physical rect conversions
751impl<T> Rect2D<T, Physical>
752where
753    T: Copy + Into<f64>,
754{
755    /// Convert to logical coordinates.
756    #[inline]
757    pub fn to_logical(self, scale: ScaleFactor) -> LogicalRect<f32> {
758        LogicalRect::new(
759            (self.x.into() / scale.0) as f32,
760            (self.y.into() / scale.0) as f32,
761            (self.width.into() / scale.0) as f32,
762            (self.height.into() / scale.0) as f32,
763        )
764    }
765
766    /// Convert to logical coordinates as f64.
767    #[inline]
768    pub fn to_logical_f64(self, scale: ScaleFactor) -> LogicalRect<f64> {
769        LogicalRect::new(
770            self.x.into() / scale.0,
771            self.y.into() / scale.0,
772            self.width.into() / scale.0,
773            self.height.into() / scale.0,
774        )
775    }
776}
777
778/// Logical (DPI-independent) rectangle.
779pub type LogicalRect<T> = Rect2D<T, Logical>;
780
781/// Physical (pixel) rectangle.
782pub type PhysicalRect<T> = Rect2D<T, Physical>;
783
784// =============================================================================
785// winit Interop
786// =============================================================================
787
788#[cfg(feature = "winit")]
789mod winit_interop {
790    use super::*;
791
792    impl From<winit::dpi::PhysicalSize<u32>> for PhysicalSize<u32> {
793        fn from(size: winit::dpi::PhysicalSize<u32>) -> Self {
794            Self::new(size.width, size.height)
795        }
796    }
797
798    impl From<PhysicalSize<u32>> for winit::dpi::PhysicalSize<u32> {
799        fn from(size: PhysicalSize<u32>) -> Self {
800            Self::new(size.width, size.height)
801        }
802    }
803
804    impl From<winit::dpi::PhysicalSize<f32>> for PhysicalSize<f32> {
805        fn from(size: winit::dpi::PhysicalSize<f32>) -> Self {
806            Self::new(size.width, size.height)
807        }
808    }
809
810    impl From<PhysicalSize<f32>> for winit::dpi::PhysicalSize<f32> {
811        fn from(size: PhysicalSize<f32>) -> Self {
812            Self::new(size.width, size.height)
813        }
814    }
815
816    impl From<winit::dpi::LogicalSize<u32>> for LogicalSize<u32> {
817        fn from(size: winit::dpi::LogicalSize<u32>) -> Self {
818            Self::new(size.width, size.height)
819        }
820    }
821
822    impl From<LogicalSize<u32>> for winit::dpi::LogicalSize<u32> {
823        fn from(size: LogicalSize<u32>) -> Self {
824            Self::new(size.width, size.height)
825        }
826    }
827
828    impl From<winit::dpi::LogicalSize<f32>> for LogicalSize<f32> {
829        fn from(size: winit::dpi::LogicalSize<f32>) -> Self {
830            Self::new(size.width, size.height)
831        }
832    }
833
834    impl From<LogicalSize<f32>> for winit::dpi::LogicalSize<f32> {
835        fn from(size: LogicalSize<f32>) -> Self {
836            Self::new(size.width, size.height)
837        }
838    }
839
840    impl From<winit::dpi::PhysicalPosition<i32>> for PhysicalPosition<i32> {
841        fn from(pos: winit::dpi::PhysicalPosition<i32>) -> Self {
842            Self::new(pos.x, pos.y)
843        }
844    }
845
846    impl From<PhysicalPosition<i32>> for winit::dpi::PhysicalPosition<i32> {
847        fn from(pos: PhysicalPosition<i32>) -> Self {
848            Self::new(pos.x, pos.y)
849        }
850    }
851
852    impl From<winit::dpi::PhysicalPosition<f64>> for PhysicalPosition<f64> {
853        fn from(pos: winit::dpi::PhysicalPosition<f64>) -> Self {
854            Self::new(pos.x, pos.y)
855        }
856    }
857
858    impl From<PhysicalPosition<f64>> for winit::dpi::PhysicalPosition<f64> {
859        fn from(pos: PhysicalPosition<f64>) -> Self {
860            Self::new(pos.x, pos.y)
861        }
862    }
863
864    impl From<winit::dpi::LogicalPosition<f64>> for LogicalPosition<f64> {
865        fn from(pos: winit::dpi::LogicalPosition<f64>) -> Self {
866            Self::new(pos.x, pos.y)
867        }
868    }
869
870    impl From<LogicalPosition<f64>> for winit::dpi::LogicalPosition<f64> {
871        fn from(pos: LogicalPosition<f64>) -> Self {
872            Self::new(pos.x, pos.y)
873        }
874    }
875}
876
877// =============================================================================
878// Generic Types (for layout calculations where coordinate space is implicit)
879// =============================================================================
880
881/// Generic 2D size without explicit coordinate space.
882///
883/// Use this for internal calculations where the coordinate space is implicit
884/// or doesn't matter (e.g., layout calculations that always work in logical space).
885///
886/// For explicit coordinate safety, prefer [`LogicalSize`] or [`PhysicalSize`].
887#[derive(Debug, Clone, Copy, PartialEq, Default)]
888pub struct Size<T> {
889    pub width: T,
890    pub height: T,
891}
892
893impl<T> Size<T> {
894    /// Create a new size.
895    #[inline]
896    pub const fn new(width: T, height: T) -> Self {
897        Self { width, height }
898    }
899}
900
901impl<T: Copy> Size<T> {
902    /// Convert to a tuple (width, height).
903    #[inline]
904    pub fn into_tuple(self) -> (T, T) {
905        (self.width, self.height)
906    }
907}
908
909impl<T: Mul<Output = T> + Copy> Mul<T> for Size<T> {
910    type Output = Self;
911
912    fn mul(self, rhs: T) -> Self::Output {
913        Self::new(self.width * rhs, self.height * rhs)
914    }
915}
916
917impl<T: Add<Output = T>> Add for Size<T> {
918    type Output = Self;
919
920    fn add(self, rhs: Self) -> Self::Output {
921        Self::new(self.width + rhs.width, self.height + rhs.height)
922    }
923}
924
925impl<T: Sub<Output = T>> Sub for Size<T> {
926    type Output = Self;
927
928    fn sub(self, rhs: Self) -> Self::Output {
929        Self::new(self.width - rhs.width, self.height - rhs.height)
930    }
931}
932
933// Conversions from typed coordinate types to generic types
934impl<T, S: CoordinateSpace> From<Size2D<T, S>> for Size<T> {
935    fn from(size: Size2D<T, S>) -> Self {
936        Self::new(size.width, size.height)
937    }
938}
939
940impl<T, S: CoordinateSpace> From<Position2D<T, S>> for Pos<T> {
941    fn from(pos: Position2D<T, S>) -> Self {
942        Self::new(pos.x, pos.y)
943    }
944}
945
946impl<T, S: CoordinateSpace> From<Rect2D<T, S>> for Rect<T> {
947    fn from(rect: Rect2D<T, S>) -> Self {
948        Self::new(rect.x, rect.y, rect.width, rect.height)
949    }
950}
951
952/// Generic 2D position without explicit coordinate space.
953///
954/// Use this for internal calculations where the coordinate space is implicit.
955/// For explicit coordinate safety, prefer [`LogicalPosition`] or [`PhysicalPosition`].
956#[derive(Debug, Clone, Copy, PartialEq, Default)]
957pub struct Pos<T> {
958    pub x: T,
959    pub y: T,
960}
961
962impl<T> Pos<T> {
963    /// Create a new position.
964    #[inline]
965    pub const fn new(x: T, y: T) -> Self {
966        Self { x, y }
967    }
968}
969
970impl<T: Copy> Pos<T> {
971    /// Convert to a tuple (x, y).
972    #[inline]
973    pub fn into_tuple(self) -> (T, T) {
974        (self.x, self.y)
975    }
976}
977
978impl<T: Mul<Output = T> + Copy> Mul<T> for Pos<T> {
979    type Output = Self;
980
981    fn mul(self, rhs: T) -> Self::Output {
982        Self::new(self.x * rhs, self.y * rhs)
983    }
984}
985
986impl<T: Add<Output = T>> Add for Pos<T> {
987    type Output = Self;
988
989    fn add(self, rhs: Self) -> Self::Output {
990        Self::new(self.x + rhs.x, self.y + rhs.y)
991    }
992}
993
994impl<T: Sub<Output = T>> Sub for Pos<T> {
995    type Output = Self;
996
997    fn sub(self, rhs: Self) -> Self::Output {
998        Self::new(self.x - rhs.x, self.y - rhs.y)
999    }
1000}
1001
1002/// Generic 2D rectangle without explicit coordinate space.
1003///
1004/// Use this for internal calculations where the coordinate space is implicit.
1005/// For explicit coordinate safety, prefer [`LogicalRect`] or [`PhysicalRect`].
1006#[derive(Debug, Clone, Copy, PartialEq, Default)]
1007pub struct Rect<T> {
1008    pub x: T,
1009    pub y: T,
1010    pub width: T,
1011    pub height: T,
1012}
1013
1014impl<T> Rect<T> {
1015    /// Create a new rectangle.
1016    #[inline]
1017    pub const fn new(x: T, y: T, width: T, height: T) -> Self {
1018        Self {
1019            x,
1020            y,
1021            width,
1022            height,
1023        }
1024    }
1025
1026    /// Create from position and size.
1027    #[inline]
1028    pub fn from_position_size(pos: Pos<T>, size: Size<T>) -> Self {
1029        Self::new(pos.x, pos.y, size.width, size.height)
1030    }
1031
1032    /// Get the position.
1033    #[inline]
1034    pub fn position(&self) -> Pos<T>
1035    where
1036        T: Copy,
1037    {
1038        Pos::new(self.x, self.y)
1039    }
1040
1041    /// Get the size.
1042    #[inline]
1043    pub fn size(&self) -> Size<T>
1044    where
1045        T: Copy,
1046    {
1047        Size::new(self.width, self.height)
1048    }
1049}
1050
1051impl<T> Rect<T>
1052where
1053    T: Copy + PartialOrd + Add<Output = T>,
1054{
1055    /// Check if a point is inside the rectangle.
1056    #[inline]
1057    pub fn contains(&self, point: Pos<T>) -> bool {
1058        point.x >= self.x
1059            && point.x < self.x + self.width
1060            && point.y >= self.y
1061            && point.y < self.y + self.height
1062    }
1063}
1064
1065// =============================================================================
1066// Tests
1067// =============================================================================
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    #[test]
1074    fn test_logical_to_physical_size() {
1075        let logical = LogicalSize::new(100.0_f64, 50.0);
1076        let scale = ScaleFactor(2.0);
1077        let physical = logical.to_physical(scale);
1078        assert_eq!(physical.width, 200);
1079        assert_eq!(physical.height, 100);
1080    }
1081
1082    #[test]
1083    fn test_physical_to_logical_size() {
1084        let physical = PhysicalSize::new(200_u32, 100);
1085        let scale = ScaleFactor(2.0);
1086        let logical = physical.to_logical(scale);
1087        assert_eq!(logical.width, 100.0);
1088        assert_eq!(logical.height, 50.0);
1089    }
1090
1091    #[test]
1092    fn test_logical_to_physical_position() {
1093        let logical = LogicalPosition::new(50.0_f64, 25.0);
1094        let scale = ScaleFactor(2.0);
1095        let physical = logical.to_physical(scale);
1096        assert_eq!(physical.x, 100);
1097        assert_eq!(physical.y, 50);
1098    }
1099
1100    #[test]
1101    fn test_rect_contains() {
1102        let rect = LogicalRect::new(10.0_f64, 10.0, 100.0, 50.0);
1103        assert!(rect.contains(LogicalPosition::new(50.0, 30.0)));
1104        assert!(!rect.contains(LogicalPosition::new(5.0, 30.0)));
1105        assert!(!rect.contains(LogicalPosition::new(50.0, 5.0)));
1106    }
1107
1108    #[test]
1109    fn test_scale_factor_inverse() {
1110        let scale = ScaleFactor(2.0);
1111        let inv = scale.inverse();
1112        assert_eq!(inv.0, 0.5);
1113    }
1114
1115    #[test]
1116    fn test_size_multiplication() {
1117        let size = LogicalSize::new(10.0_f32, 20.0);
1118        let scaled = size * 2.0;
1119        assert_eq!(scaled.width, 20.0);
1120        assert_eq!(scaled.height, 40.0);
1121    }
1122
1123    #[test]
1124    fn test_position_arithmetic() {
1125        let a = LogicalPosition::new(10.0_f32, 20.0);
1126        let b = LogicalPosition::new(5.0, 10.0);
1127        let sum = a + b;
1128        let diff = a - b;
1129        assert_eq!(sum.x, 15.0);
1130        assert_eq!(sum.y, 30.0);
1131        assert_eq!(diff.x, 5.0);
1132        assert_eq!(diff.y, 10.0);
1133    }
1134
1135    #[test]
1136    fn test_rect_from_position_size() {
1137        let pos = LogicalPosition::new(10.0_f32, 20.0);
1138        let size = LogicalSize::new(100.0_f32, 50.0);
1139        let rect = LogicalRect::from_position_size(pos, size);
1140        assert_eq!(rect.x, 10.0);
1141        assert_eq!(rect.y, 20.0);
1142        assert_eq!(rect.width, 100.0);
1143        assert_eq!(rect.height, 50.0);
1144    }
1145}