Skip to main content

pax_runtime/
layout.rs

1use std::ops::Mul;
2
3#[allow(unused)]
4use pax_runtime_api::math::Generic;
5use pax_runtime_api::math::{Point2, Space, TransformParts};
6use pax_runtime_api::{Interpolatable, LocalProperty, Percent, Rotation, Window};
7
8use crate::api::math::{Transform2, Vector2};
9use crate::api::{Axis, LayoutRole, Size, Transform2D};
10use crate::node_interface::NodeLocal;
11use crate::ContainerFrame;
12
13/// Compute a reactive `TransformAndBounds` property from layout properties plus parent geometry.
14pub fn compute_tab(
15    layout_properties: LocalProperty<LayoutProperties>,
16    extra_transform: LocalProperty<Option<Transform2D>>,
17    container_transform_and_bounds: LocalProperty<TransformAndBounds<NodeLocal, Window>>,
18) -> LocalProperty<TransformAndBounds<NodeLocal, Window>> {
19    // get the size of this node (calc'd or otherwise) and use
20    // it as the new accumulated bounds: both for this node's children (their parent container bounds)
21    // and for this node itself (e.g. for specifying the size of a Rectangle node)
22    let deps = [
23        layout_properties.untyped(),
24        container_transform_and_bounds.untyped(),
25        extra_transform.untyped(),
26    ];
27
28    LocalProperty::computed(
29        move || {
30            let container_t_and_b = container_transform_and_bounds.get();
31            layout_properties.read(|layout_properties| {
32                let transform_and_bounds =
33                    calculate_transform_and_bounds(&layout_properties, container_t_and_b.clone());
34                let extra_transform = extra_transform.get();
35                if let Some(transform) = extra_transform {
36                    transform.apply(transform_and_bounds)
37                } else {
38                    transform_and_bounds
39                }
40            })
41        },
42        &deps,
43    )
44}
45
46/// Apply a container-assigned child frame on top of the parent geometry.
47///
48/// This is the geometry seam where container-owned placement can cooperate
49/// with descendant-authored layout and future bottom-up measurement.
50pub fn apply_container_frame(
51    container_transform_and_bounds: TransformAndBounds<NodeLocal, Window>,
52    container_frame: Option<ContainerFrame>,
53) -> TransformAndBounds<NodeLocal, Window> {
54    match container_frame {
55        Some(frame) => TransformAndBounds {
56            transform: container_transform_and_bounds.transform * frame.transform,
57            bounds: frame.bounds,
58        },
59        None => container_transform_and_bounds,
60    }
61}
62
63/// Apply a node's padding to the container geometry seen by its children.
64pub fn apply_padding_frame(
65    container_transform_and_bounds: TransformAndBounds<NodeLocal, Window>,
66    padding_x: Option<Size>,
67    padding_y: Option<Size>,
68) -> TransformAndBounds<NodeLocal, Window> {
69    let padding_x = padding_x
70        .map(|padding| {
71            padding
72                .evaluate(container_transform_and_bounds.bounds, Axis::X)
73                .max(0.0)
74        })
75        .unwrap_or(0.0);
76    let padding_y = padding_y
77        .map(|padding| {
78            padding
79                .evaluate(container_transform_and_bounds.bounds, Axis::Y)
80                .max(0.0)
81        })
82        .unwrap_or(0.0);
83
84    if padding_x == 0.0 && padding_y == 0.0 {
85        return container_transform_and_bounds;
86    }
87
88    TransformAndBounds {
89        transform: container_transform_and_bounds.transform
90            * Transform2::translate(Vector2::new(padding_x, padding_y)),
91        bounds: (
92            (container_transform_and_bounds.bounds.0 - (2.0 * padding_x)).max(0.0),
93            (container_transform_and_bounds.bounds.1 - (2.0 * padding_y)).max(0.0),
94        ),
95    }
96}
97
98/// Solve an autosized outer axis from measured content and symmetric padding.
99pub fn resolve_padded_autosize_axis(content_extent: f64, padding: Option<Size>) -> Option<f64> {
100    let Some(padding) = padding else {
101        return Some(content_extent.max(0.0));
102    };
103
104    let (pixel_component, percent_component) = match padding {
105        Size::Pixels(pixels) => (pixels.to_float().max(0.0), 0.0),
106        Size::Percent(percent) => (0.0, percent.to_float().max(0.0) / 100.0),
107        Size::Combined(pixels, percent) => (
108            pixels.to_float().max(0.0),
109            percent.to_float().max(0.0) / 100.0,
110        ),
111    };
112
113    let denominator = 1.0 - (2.0 * percent_component);
114    (denominator > f64::EPSILON)
115        .then_some(((content_extent.max(0.0) + (2.0 * pixel_component)) / denominator).max(0.0))
116}
117
118/// Per-axis local extents contributed by a node subtree for container measurement.
119///
120/// Coordinates are expressed in the node's local layout space. Validity is tracked
121/// per axis so parent-dependent axes can be ignored without discarding the entire
122/// subtree.
123#[derive(Clone, Copy, Debug, Default, PartialEq)]
124pub struct LayoutHull {
125    pub min_x: f64,
126    pub max_x: f64,
127    pub min_y: f64,
128    pub max_y: f64,
129    pub valid_x: bool,
130    pub valid_y: bool,
131}
132
133impl Interpolatable for LayoutHull {}
134
135const LAYOUT_MEASUREMENT_EPSILON: f64 = 1e-9;
136
137pub(crate) fn layout_hulls_equivalent(previous: &LayoutHull, candidate: &LayoutHull) -> bool {
138    previous.valid_x == candidate.valid_x
139        && previous.valid_y == candidate.valid_y
140        && layout_measurements_equivalent(previous.min_x, candidate.min_x)
141        && layout_measurements_equivalent(previous.max_x, candidate.max_x)
142        && layout_measurements_equivalent(previous.min_y, candidate.min_y)
143        && layout_measurements_equivalent(previous.max_y, candidate.max_y)
144}
145
146fn layout_measurements_equivalent(previous: f64, candidate: f64) -> bool {
147    if previous == candidate {
148        return true;
149    }
150    if !previous.is_finite() || !candidate.is_finite() {
151        return previous.to_bits() == candidate.to_bits();
152    }
153    (previous - candidate).abs() <= LAYOUT_MEASUREMENT_EPSILON
154}
155
156impl LayoutHull {
157    pub fn from_bounds(bounds: (f64, f64)) -> Self {
158        Self::from_axis_ranges(Some((0.0, bounds.0)), Some((0.0, bounds.1)))
159    }
160
161    pub fn from_axis_ranges(x: Option<(f64, f64)>, y: Option<(f64, f64)>) -> Self {
162        let (min_x, max_x, valid_x) = match x {
163            Some((min_x, max_x)) => {
164                let (min_x, max_x) = order_pair(min_x, max_x);
165                (min_x, max_x, true)
166            }
167            None => (0.0, 0.0, false),
168        };
169        let (min_y, max_y, valid_y) = match y {
170            Some((min_y, max_y)) => {
171                let (min_y, max_y) = order_pair(min_y, max_y);
172                (min_y, max_y, true)
173            }
174            None => (0.0, 0.0, false),
175        };
176        Self {
177            min_x,
178            max_x,
179            min_y,
180            max_y,
181            valid_x,
182            valid_y,
183        }
184    }
185
186    pub fn x_range(&self) -> Option<(f64, f64)> {
187        self.valid_x.then_some((self.min_x, self.max_x))
188    }
189
190    pub fn y_range(&self) -> Option<(f64, f64)> {
191        self.valid_y.then_some((self.min_y, self.max_y))
192    }
193
194    pub fn union(self, other: Self) -> Self {
195        Self::from_axis_ranges(
196            union_axis_ranges(self.x_range(), other.x_range()),
197            union_axis_ranges(self.y_range(), other.y_range()),
198        )
199    }
200
201    pub fn forward_extent_x(&self) -> Option<f64> {
202        self.x_range().map(|(_, max_x)| max_x.max(0.0))
203    }
204
205    pub fn forward_extent_y(&self) -> Option<f64> {
206        self.y_range().map(|(_, max_y)| max_y.max(0.0))
207    }
208}
209
210/// Project a local layout hull through the provided transform and return the
211/// axis-aligned hull in the destination coordinate space.
212pub fn project_layout_hull<F: Space, T: Space>(
213    transform: Transform2<F, T>,
214    hull: LayoutHull,
215) -> LayoutHull {
216    let mixes_axes = transform.m[1].abs() > f64::EPSILON || transform.m[2].abs() > f64::EPSILON;
217    if hull.valid_x && hull.valid_y {
218        let corners = [
219            transform * Point2::new(hull.min_x, hull.min_y),
220            transform * Point2::new(hull.min_x, hull.max_y),
221            transform * Point2::new(hull.max_x, hull.min_y),
222            transform * Point2::new(hull.max_x, hull.max_y),
223        ];
224        let (min_x, max_x) = corners.iter().fold(
225            (f64::INFINITY, f64::NEG_INFINITY),
226            |(min_x, max_x), point| (min_x.min(point.x), max_x.max(point.x)),
227        );
228        let (min_y, max_y) = corners.iter().fold(
229            (f64::INFINITY, f64::NEG_INFINITY),
230            |(min_y, max_y), point| (min_y.min(point.y), max_y.max(point.y)),
231        );
232        return LayoutHull::from_axis_ranges(Some((min_x, max_x)), Some((min_y, max_y)));
233    }
234
235    if mixes_axes {
236        return LayoutHull::default();
237    }
238
239    let x = hull
240        .x_range()
241        .map(|(min_x, max_x)| project_axis_range(min_x, max_x, transform.m[0], transform.m[4]));
242    let y = hull
243        .y_range()
244        .map(|(min_y, max_y)| project_axis_range(min_y, max_y, transform.m[3], transform.m[5]));
245    LayoutHull::from_axis_ranges(x, y)
246}
247
248/// Project a child's local hull into its parent's local layout space.
249pub fn project_child_layout_hull_to_parent_space(
250    parent: TransformAndBounds<NodeLocal, Window>,
251    child: TransformAndBounds<NodeLocal, Window>,
252    child_hull: LayoutHull,
253) -> LayoutHull {
254    let relative_transform = parent.transform.inverse() * child.transform;
255    // Layout hulls describe flow-space measurement. Presentation transforms such
256    // as rotation, scale, and skew should not make autosize parents expand or
257    // lose partial-axis measurements while an element animates.
258    let layout_transform: Transform2<NodeLocal, NodeLocal> = Transform2::translate(Vector2::new(
259        relative_transform.m[4],
260        relative_transform.m[5],
261    ));
262    project_layout_hull(layout_transform, child_hull)
263}
264
265/// Publish a child's layout hull in a parent's padded local space.
266///
267/// The cutoff sits at the coordinate-space cancellation boundary: translating an
268/// ancestor changes both world transforms, but not the resulting parent-local hull.
269pub(crate) fn projected_child_layout_hull_property(
270    parent: LocalProperty<TransformAndBounds<NodeLocal, Window>>,
271    padding_x: LocalProperty<Option<Size>>,
272    padding_y: LocalProperty<Option<Size>>,
273    child: LocalProperty<TransformAndBounds<NodeLocal, Window>>,
274    child_hull: LocalProperty<LayoutHull>,
275    child_layout_role: LocalProperty<Option<LayoutRole>>,
276    name: &str,
277) -> LocalProperty<LayoutHull> {
278    let deps = [
279        parent.untyped(),
280        padding_x.untyped(),
281        padding_y.untyped(),
282        child.untyped(),
283        child_hull.untyped(),
284        child_layout_role.untyped(),
285    ];
286    LocalProperty::computed_with_cutoff_and_name(
287        move || {
288            if child_layout_role.get() == Some(LayoutRole::Breakout) {
289                return LayoutHull::default();
290            }
291            let parent = apply_padding_frame(parent.get(), padding_x.get(), padding_y.get());
292            project_child_layout_hull_to_parent_space(parent, child.get(), child_hull.get())
293        },
294        &deps,
295        layout_hulls_equivalent,
296        name,
297    )
298}
299
300/// Expand a content-space hull into the node's padded outer layout space.
301///
302/// Children are laid out inside the leading padding offset, but autosized
303/// bounds must be solved from the content hull itself. Solving the outer size
304/// first keeps percentage padding from feeding back through the node's current
305/// measured bounds.
306pub fn add_symmetric_padding_to_content_layout_hull(
307    hull: LayoutHull,
308    padding_x: Option<Size>,
309    padding_y: Option<Size>,
310) -> LayoutHull {
311    LayoutHull::from_axis_ranges(
312        hull.x_range().and_then(|(min_x, max_x)| {
313            resolve_symmetric_padded_axis_range(min_x, max_x, padding_x)
314        }),
315        hull.y_range().and_then(|(min_y, max_y)| {
316            resolve_symmetric_padded_axis_range(min_y, max_y, padding_y)
317        }),
318    )
319}
320
321fn resolve_symmetric_padded_axis_range(
322    min_extent: f64,
323    max_extent: f64,
324    padding: Option<Size>,
325) -> Option<(f64, f64)> {
326    let Some(padding) = padding else {
327        return Some((min_extent, max_extent));
328    };
329
330    let outer_extent = resolve_padded_autosize_axis(max_extent.max(0.0), Some(padding))?;
331    let leading_padding = evaluate_padding_against_resolved_extent(padding, outer_extent)?;
332    Some((
333        min_extent + leading_padding,
334        outer_extent.max(max_extent + leading_padding),
335    ))
336}
337
338fn evaluate_padding_against_resolved_extent(padding: Size, outer_extent: f64) -> Option<f64> {
339    let (pixel_component, percent_component) = padding_components(padding);
340    let padding = pixel_component + (percent_component * outer_extent.max(0.0));
341    padding.is_finite().then_some(padding.max(0.0))
342}
343
344fn padding_components(padding: Size) -> (f64, f64) {
345    match padding {
346        Size::Pixels(pixels) => (pixels.to_float().max(0.0), 0.0),
347        Size::Percent(percent) => (0.0, percent.to_float().max(0.0) / 100.0),
348        Size::Combined(pixels, percent) => (
349            pixels.to_float().max(0.0),
350            percent.to_float().max(0.0) / 100.0,
351        ),
352    }
353}
354
355fn order_pair(a: f64, b: f64) -> (f64, f64) {
356    if a <= b {
357        (a, b)
358    } else {
359        (b, a)
360    }
361}
362
363fn union_axis_ranges(lhs: Option<(f64, f64)>, rhs: Option<(f64, f64)>) -> Option<(f64, f64)> {
364    match (lhs, rhs) {
365        (Some((lhs_min, lhs_max)), Some((rhs_min, rhs_max))) => {
366            Some((lhs_min.min(rhs_min), lhs_max.max(rhs_max)))
367        }
368        (Some(range), None) | (None, Some(range)) => Some(range),
369        (None, None) => None,
370    }
371}
372
373fn project_axis_range(min: f64, max: f64, scale: f64, translate: f64) -> (f64, f64) {
374    order_pair(scale * min + translate, scale * max + translate)
375}
376
377/// Resolve one set of layout properties into concrete bounds and a window-space transform.
378pub fn calculate_transform_and_bounds(
379    LayoutProperties {
380        width,
381        height,
382        anchor_x,
383        anchor_y,
384        x,
385        y,
386        rotate,
387        scale_x,
388        scale_y,
389        skew_x,
390        skew_y,
391    }: &LayoutProperties,
392    TransformAndBounds {
393        transform: container_transform,
394        bounds: container_bounds,
395    }: TransformAndBounds<NodeLocal, Window>,
396) -> TransformAndBounds<NodeLocal, Window> {
397    let x = x.unwrap_or(Size::ZERO());
398    let y = y.unwrap_or(Size::ZERO());
399    let width = width
400        .map(|v| v.evaluate(container_bounds, Axis::X))
401        .unwrap_or(container_bounds.0);
402    let height = height
403        .map(|v| v.evaluate(container_bounds, Axis::Y))
404        .unwrap_or(container_bounds.1);
405    let origin = Vector2::new(
406        x.evaluate(container_bounds, Axis::X),
407        y.evaluate(container_bounds, Axis::Y),
408    );
409
410    let bounds = (width, height);
411
412    // Anchor behavior:  if no anchor is specified and if x/y values are present
413    // and have an explicit percent value or component, use those percent values (clamp 100% and 0%)
414    // otherwise, default to 0
415    let anchor_x = anchor_x.unwrap_or_else(|| match x {
416        Size::Pixels(_) => Size::ZERO(),
417        Size::Combined(_, per) | Size::Percent(per) => {
418            if per.to_float() > 100.0 {
419                Size::default()
420            } else if per.to_float() < 0.0 {
421                Size::ZERO()
422            } else {
423                Size::Percent(per)
424            }
425        }
426    });
427
428    let anchor_y = anchor_y.unwrap_or_else(|| match y {
429        Size::Pixels(_) => Size::ZERO(),
430        Size::Combined(_, per) | Size::Percent(per) => {
431            if per.to_float() > 100.0 {
432                Size::default()
433            } else if per.to_float() < 0.0 {
434                Size::ZERO()
435            } else {
436                Size::Percent(per)
437            }
438        }
439    });
440
441    let anchor_transform = Transform2::translate(Vector2::new(
442        -anchor_x.evaluate(bounds, Axis::X),
443        -anchor_y.evaluate(bounds, Axis::Y),
444    ));
445
446    let scale = Vector2::new(
447        scale_x
448            .as_ref()
449            .map(|s| s.0.to_float() / 100.0)
450            .unwrap_or(1.0),
451        scale_y
452            .as_ref()
453            .map(|s| s.0.to_float() / 100.0)
454            .unwrap_or(1.0),
455    );
456
457    let skew = Vector2::new(
458        skew_x.map(|s| s.get_as_radians()).unwrap_or(0.0),
459        skew_y.map(|s| s.get_as_radians()).unwrap_or(0.0),
460    );
461
462    let rotation = rotate.map(|s| s.get_as_radians()).unwrap_or(0.0);
463
464    let parts = TransformParts {
465        origin,
466        scale,
467        skew,
468        rotation,
469    };
470
471    let combined_transform: Transform2<NodeLocal, NodeLocal> = parts.into();
472
473    TransformAndBounds {
474        transform: container_transform * combined_transform * anchor_transform,
475        bounds,
476    }
477}
478
479impl<F: Space, T: Space> Interpolatable for TransformAndBounds<F, T> {
480    fn interpolate(&self, other: &Self, t: f64) -> Self {
481        TransformAndBounds {
482            transform: self.transform.interpolate(&other.transform, t),
483            bounds: (
484                self.bounds.0 + (other.bounds.0 - self.bounds.0) * t,
485                self.bounds.1 + (other.bounds.1 - self.bounds.1) * t,
486            ),
487        }
488    }
489}
490
491/// Pax's canonical representation of position, size, and transform, encoded
492/// as a transform (translation, rotation, scale, skew) and a separate width/height (bounds) value.
493/// Bounds are expressed as the (x1, y1) values of the axis-aligned pre-transform bounding box,
494/// where (x0, y0) are the origin.
495///
496/// In this model, position is a derived property, calculated by applying the transform to the bounding box.
497#[derive(PartialEq)]
498pub struct TransformAndBounds<F, T = F> {
499    pub transform: Transform2<F, T>,
500    pub bounds: (f64, f64),
501}
502
503impl<F: Space, T: Space> std::fmt::Debug for TransformAndBounds<F, T> {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        f.debug_struct("TransformAndBounds")
506            .field("transform", &self.transform)
507            .field("bounds", &self.bounds)
508            .finish()
509    }
510}
511
512impl PartialEq for TransformAndBounds<NodeLocal, Window> {
513    fn eq(&self, other: &Self) -> bool {
514        self.transform == other.transform && self.bounds == other.bounds
515    }
516}
517
518impl<F: Space, T: Space> Default for TransformAndBounds<F, T> {
519    fn default() -> Self {
520        Self {
521            transform: Default::default(),
522            bounds: (100.0, 100.0),
523        }
524    }
525}
526
527impl<F, T> Clone for TransformAndBounds<F, T> {
528    fn clone(&self) -> Self {
529        Self {
530            transform: self.transform.clone(),
531            bounds: self.bounds.clone(),
532        }
533    }
534}
535
536impl<F, T> Copy for TransformAndBounds<F, T> {}
537
538impl<W1: Space, W2: Space, W3: Space> Mul<TransformAndBounds<W1, W2>>
539    for TransformAndBounds<W2, W3>
540{
541    type Output = TransformAndBounds<W1, W3>;
542
543    // - if T(M) = M.transform * Transform::scale_sep(M.bounds.0, M.bounds.1),
544    //   then T(A) * T(B) = T(A*B).
545    // (
546    // - some other rule regarding multiplying scaling and width/height values being constant,
547    //   related to how width/height scale x/y change for A or B, how that affects A*B.
548    // TODO figure this rule out, this is that would fix resize skew introducing un-needed scaling
549    // (compare with figma)
550    //)
551    fn mul(self, rhs: TransformAndBounds<W1, W2>) -> Self::Output {
552        let s_s = Transform2::scale_sep(Vector2::new(self.bounds.0, self.bounds.1));
553        let r_s = Transform2::scale_sep(Vector2::new(rhs.bounds.0, rhs.bounds.1));
554
555        let s_t = self.transform * s_s;
556        let r_t = rhs.transform * r_s;
557        let res = s_t * r_t * s_s.inverse() * r_s.inverse();
558
559        TransformAndBounds {
560            transform: res,
561            bounds: (self.bounds.0 * rhs.bounds.0, self.bounds.1 * rhs.bounds.1),
562        }
563    }
564}
565
566impl<F: Space, T: Space> TransformAndBounds<F, T> {
567    /// Center point of this transformed box.
568    pub fn center(&self) -> Point2<T> {
569        let (o, u, v) = self.transform.decompose();
570        let u = u * self.bounds.0;
571        let v = v * self.bounds.1;
572        o + v / 2.0 + u / 2.0
573    }
574
575    /// Corners of this transformed box, starting at origin and proceeding around the rectangle.
576    pub fn corners(&self) -> [Point2<T>; 4] {
577        let (o, u, v) = self.transform.decompose();
578        let u = u * self.bounds.0;
579        let v = v * self.bounds.1;
580        [o, o + v, o + u + v, o + u]
581    }
582
583    /// Test whether a point falls inside this transformed box.
584    pub fn contains_point(&self, point: Point2<T>) -> bool {
585        self.as_transform().contains_point(point)
586    }
587
588    /// Move scale from the transform into the bounds field.
589    pub fn as_pure_size(self) -> Self {
590        let mut parts: TransformParts = self.transform.into();
591        let bounds_x = std::mem::replace(&mut parts.scale.x, 1.0);
592        let bounds_y = std::mem::replace(&mut parts.scale.y, 1.0);
593        TransformAndBounds {
594            transform: parts.into(),
595            bounds: (self.bounds.0 * bounds_x, self.bounds.1 * bounds_y),
596        }
597    }
598    /// Move bounds into the transform as scale, leaving unit bounds.
599    pub fn as_pure_scale(self) -> Self {
600        TransformAndBounds {
601            transform: self.transform
602                * Transform2::scale_sep(Vector2::new(self.bounds.0, self.bounds.1)),
603            bounds: (1.0, 1.0),
604        }
605    }
606
607    /// Retype coordinate-space markers without changing numeric values.
608    pub fn cast_spaces<A: Space, B: Space>(self) -> TransformAndBounds<A, B> {
609        TransformAndBounds {
610            transform: self.transform.cast_spaces(),
611            bounds: self.bounds,
612        }
613    }
614
615    /// Convert this split representation into a single affine transform.
616    pub fn as_transform(&self) -> Transform2<F, T> {
617        self.transform * Transform2::scale_sep(Vector2::new(self.bounds.0, self.bounds.1))
618    }
619}
620
621#[test]
622fn test_transform_and_bounds_mult() {
623    let dvx = 0.6;
624    let dvy = 0.3;
625
626    let t_and_b_with_scale = TransformAndBounds::<Generic> {
627        transform: Transform2::new([1.5, 1.1, 2.3, 3.2, 1.2, 1.0])
628            * Transform2::scale_sep(Vector2::<Generic>::new(dvx, dvy)),
629        bounds: (2.1, 1.2),
630    };
631
632    let t_and_b_with_size = TransformAndBounds::<Generic> {
633        transform: Transform2::new([1.5, 1.1, 2.3, 3.2, 1.2, 1.0]),
634        bounds: (2.1 * dvx, 1.2 * dvy),
635    };
636    let some_other_transform = TransformAndBounds::<Generic> {
637        transform: Transform2::new([1.1, 1.2, 5.3, 9.2, 1.0, 2.0]),
638        bounds: (1.9, 4.5),
639    };
640
641    let res_scale = t_and_b_with_scale * some_other_transform;
642    let res_size = t_and_b_with_size * some_other_transform;
643
644    let t_scale = res_scale.transform
645        * Transform2::<Generic>::scale_sep(Vector2::new(res_scale.bounds.0, res_scale.bounds.1));
646    let t_scale_c = t_scale.coeffs();
647    let t_size = res_size.transform
648        * Transform2::<Generic>::scale_sep(Vector2::new(res_size.bounds.0, res_size.bounds.1));
649    let t_size_c = t_size.coeffs();
650    let diff_sum = t_scale_c
651        .iter()
652        .zip(t_size_c)
653        .map(|(a, b)| (a - b).abs())
654        .sum::<f64>();
655    assert!(diff_sum < 1e-4);
656
657    let res_scale_other_way = some_other_transform * t_and_b_with_scale;
658    let res_size_other_way = some_other_transform * t_and_b_with_size;
659
660    let t_scale = res_scale_other_way.transform
661        * Transform2::<Generic>::scale_sep(Vector2::new(
662            res_scale_other_way.bounds.0,
663            res_scale_other_way.bounds.1,
664        ));
665    let t_scale_c = t_scale.coeffs();
666    let t_size = res_size_other_way.transform
667        * Transform2::<Generic>::scale_sep(Vector2::new(
668            res_size_other_way.bounds.0,
669            res_size_other_way.bounds.1,
670        ));
671    let t_size_c = t_size.coeffs();
672    let diff_sum = t_scale_c
673        .iter()
674        .zip(t_size_c)
675        .map(|(a, b)| (a - b).abs())
676        .sum::<f64>();
677    assert!(diff_sum < 1e-4);
678}
679
680#[test]
681fn test_apply_container_frame_uses_assigned_bounds() {
682    let parent = TransformAndBounds::<NodeLocal, Window> {
683        transform: Transform2::translate(Vector2::new(50.0, 60.0)),
684        bounds: (100.0, 100.0),
685    };
686    let frame = ContainerFrame {
687        transform: Transform2::translate(Vector2::new(10.0, 20.0)),
688        bounds: (30.0, 40.0),
689    };
690
691    let result = apply_container_frame(parent, Some(frame));
692
693    assert_eq!(result.bounds, (30.0, 40.0));
694    assert_eq!(result.transform.m[4], 60.0);
695    assert_eq!(result.transform.m[5], 80.0);
696}
697
698#[test]
699fn test_apply_padding_frame_shrinks_and_offsets_child_bounds() {
700    let parent = TransformAndBounds::<NodeLocal, Window> {
701        transform: Transform2::translate(Vector2::new(50.0, 60.0)),
702        bounds: (200.0, 100.0),
703    };
704
705    let result = apply_padding_frame(
706        parent,
707        Some(Size::Pixels(10.into())),
708        Some(Size::Percent(20.into())),
709    );
710
711    assert_eq!(result.bounds, (180.0, 60.0));
712    assert_eq!(result.transform.m[4], 60.0);
713    assert_eq!(result.transform.m[5], 80.0);
714}
715
716#[test]
717fn test_resolve_padded_autosize_axis_solves_percent_padding() {
718    let resolved = resolve_padded_autosize_axis(60.0, Some(Size::Percent(20.into()))).unwrap();
719
720    assert!((resolved - 100.0).abs() < 1e-9);
721}
722
723#[test]
724fn test_add_symmetric_padding_to_content_layout_hull_solves_from_content() {
725    let hull = LayoutHull::from_axis_ranges(Some((0.0, 50.0)), Some((4.0, 84.0)));
726    let padded = add_symmetric_padding_to_content_layout_hull(
727        hull,
728        Some(Size::Pixels(5.into())),
729        Some(Size::Percent(10.into())),
730    );
731
732    assert_eq!(padded.x_range(), Some((5.0, 60.0)));
733    assert!((padded.y_range().unwrap().0 - 14.5).abs() < 1e-9);
734    assert!((padded.forward_extent_y().unwrap() - 105.0).abs() < 1e-9);
735}
736
737#[test]
738fn test_project_layout_hull_with_translation_preserves_forward_extents() {
739    let hull = LayoutHull::from_axis_ranges(Some((-10.0, 40.0)), Some((0.0, 20.0)));
740    let projected = project_layout_hull(
741        Transform2::<Generic>::translate(Vector2::new(30.0, -5.0)),
742        hull,
743    );
744
745    assert_eq!(projected.x_range(), Some((20.0, 70.0)));
746    assert_eq!(projected.y_range(), Some((-5.0, 15.0)));
747    assert_eq!(projected.forward_extent_x(), Some(70.0));
748    assert_eq!(projected.forward_extent_y(), Some(15.0));
749}
750
751#[test]
752fn test_project_layout_hull_keeps_axis_independence_without_cross_axis_mixing() {
753    let hull = LayoutHull::from_axis_ranges(Some((10.0, 30.0)), None);
754    let projected = project_layout_hull(
755        Transform2::<Generic>::new([-2.0, 0.0, 0.0, 3.0, 5.0, 7.0]),
756        hull,
757    );
758
759    assert_eq!(projected.x_range(), Some((-55.0, -15.0)));
760    assert_eq!(projected.y_range(), None);
761}
762
763#[test]
764fn test_project_layout_hull_invalidates_partial_axes_when_transform_mixes_axes() {
765    let hull = LayoutHull::from_axis_ranges(Some((0.0, 20.0)), None);
766    let projected = project_layout_hull(
767        Transform2::<Generic>::rotate(std::f64::consts::FRAC_PI_4),
768        hull,
769    );
770
771    assert_eq!(projected, LayoutHull::default());
772}
773
774#[test]
775fn test_project_child_layout_hull_ignores_rotation_for_flow_measurement() {
776    let parent = TransformAndBounds::<NodeLocal, Window> {
777        transform: Transform2::identity(),
778        bounds: (100.0, 100.0),
779    };
780    let child = TransformAndBounds::<NodeLocal, Window> {
781        transform: Transform2::<NodeLocal, Window>::translate(Vector2::new(10.0, 20.0))
782            * Transform2::<NodeLocal, NodeLocal>::rotate(std::f64::consts::FRAC_PI_4),
783        bounds: (100.0, 100.0),
784    };
785    let hull = LayoutHull::from_axis_ranges(None, Some((0.0, 50.0)));
786
787    let projected = project_child_layout_hull_to_parent_space(parent, child, hull);
788
789    assert_eq!(projected.x_range(), None);
790    assert_eq!(projected.y_range(), Some((20.0, 70.0)));
791}
792
793#[test]
794fn test_layout_hull_equivalence_ignores_numeric_jitter() {
795    let previous = LayoutHull::from_axis_ranges(Some((10.0, 30.0)), Some((20.0, 40.0000000000001)));
796    let equivalent =
797        LayoutHull::from_axis_ranges(Some((10.0, 30.0000000000001)), Some((20.0, 40.0)));
798    let changed = LayoutHull::from_axis_ranges(Some((10.0, 30.001)), Some((20.0, 40.0)));
799
800    assert!(layout_hulls_equivalent(&previous, &equivalent));
801    assert!(!layout_hulls_equivalent(&previous, &changed));
802}
803
804impl Interpolatable for LayoutProperties {}
805
806#[derive(Debug, Default, Clone)]
807/// Unresolved layout inputs copied out of common properties before geometry calculation.
808pub struct LayoutProperties {
809    pub x: Option<Size>,
810    pub y: Option<Size>,
811    pub width: Option<Size>,
812    pub height: Option<Size>,
813    pub rotate: Option<Rotation>,
814    pub scale_x: Option<Percent>,
815    pub scale_y: Option<Percent>,
816    pub anchor_x: Option<Size>,
817    pub anchor_y: Option<Size>,
818    pub skew_x: Option<Rotation>,
819    pub skew_y: Option<Rotation>,
820}
821
822impl LayoutProperties {
823    /// Full-size defaults for nodes that should fill their containing bounds.
824    pub fn fill() -> Self {
825        Self {
826            x: Some(Size::ZERO()),
827            y: Some(Size::ZERO()),
828            width: Some(Size::default()),
829            height: Some(Size::default()),
830            rotate: Some(Rotation::ZERO()),
831            scale_x: Some(Percent(100.into())),
832            scale_y: Some(Percent(100.into())),
833            anchor_x: None,
834            anchor_y: None,
835            skew_x: Some(Rotation::ZERO()),
836            skew_y: Some(Rotation::ZERO()),
837        }
838    }
839}
840
841impl<F: Space, T: Space> TransformAndBounds<F, T> {
842    /// Invert the transform-and-bounds mapping.
843    pub fn inverse(&self) -> TransformAndBounds<T, F> {
844        let t_inv = self.transform.inverse();
845        let b_inv = (1.0 / self.bounds.0, 1.0 / self.bounds.1);
846        TransformAndBounds {
847            transform: t_inv,
848            bounds: b_inv,
849        }
850    }
851
852    /// Test transformed-box intersection using the separating axis theorem.
853    pub fn intersects(&self, other: &Self) -> bool {
854        let corners_self = self.corners();
855        let corners_other = other.corners();
856
857        for i in 0..2 {
858            let axis = (corners_self[i] - corners_self[(i + 1) % 4]).normal();
859
860            let self_projections: Vec<_> = corners_self
861                .iter()
862                .map(|&p| p.to_vector().project_onto(axis).length())
863                .collect();
864            let other_projections: Vec<_> = corners_other
865                .iter()
866                .map(|&p| p.to_vector().project_onto(axis).length())
867                .collect();
868
869            let (min_self, max_self) = min_max_projections(&self_projections);
870            let (min_other, max_other) = min_max_projections(&other_projections);
871
872            // Check for non-overlapping projections
873            if max_self < min_other || max_other < min_self {
874                // By the separating axis theorem, non-overlap of projections on _any one_ of the axis-normals proves that these polygons do not intersect.
875                return false;
876            }
877        }
878        true
879    }
880}
881
882fn min_max_projections(projections: &[f64]) -> (f64, f64) {
883    let min_projection = *projections.iter().min_by(|a, b| a.total_cmp(b)).unwrap();
884    let max_projection = *projections.iter().max_by(|a, b| a.total_cmp(b)).unwrap();
885    (min_projection, max_projection)
886}
887
888pub trait ComputableTransform<F, T> {
889    fn apply(&self, bounds: TransformAndBounds<F, T>) -> TransformAndBounds<F, T>;
890}
891
892impl ComputableTransform<NodeLocal, Window> for Transform2D {
893    fn apply(
894        &self,
895        bounds: TransformAndBounds<NodeLocal, Window>,
896    ) -> TransformAndBounds<NodeLocal, Window> {
897        let layout_properties = LayoutProperties {
898            x: self.translate.map(|v| v[0]),
899            y: self.translate.map(|v| v[1]),
900            width: Some(Size::Pixels(bounds.bounds.0.into())),
901            height: Some(Size::Pixels(bounds.bounds.1.into())),
902            rotate: self.rotate.clone(),
903            scale_x: self
904                .scale
905                .as_ref()
906                .map(|v| Percent((100.0 * v[0].clone().expect_percent()).into())),
907            scale_y: self
908                .scale
909                .as_ref()
910                .map(|v| Percent((100.0 * v[1].clone().expect_percent()).into())),
911            anchor_x: self.anchor.map(|v| v[0]),
912            anchor_y: self.anchor.map(|v| v[1]),
913            skew_x: self.skew.as_ref().map(|v| v[0].clone()),
914            skew_y: self.skew.as_ref().map(|v| v[1].clone()),
915        };
916
917        let curr = calculate_transform_and_bounds(&layout_properties, bounds.clone());
918        match &self.previous {
919            Some(previous) => (*previous).apply(curr),
920            None => curr,
921        }
922    }
923}