Skip to main content

appcore_filemaker/
source_transform.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: source_transform.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11use serde::{Deserialize, Serialize};
12
13use crate::{ErrorCode, FileMakerError, Length, Result, TransformIr, Unit};
14
15/// Declarative fixed-point transform resolved around an element-local origin.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct TransformSource {
19    /// Horizontal translation; percentages use element width.
20    pub translate_x: Length,
21    /// Vertical translation; percentages use element height.
22    pub translate_y: Length,
23    /// Clockwise rotation in integer degrees.
24    pub rotate: i32,
25    /// Horizontal scale in millionths (`1_000_000` is 100%).
26    pub scale_x: i64,
27    /// Vertical scale in millionths (`1_000_000` is 100%).
28    pub scale_y: i64,
29    /// Explicit horizontal flip.
30    pub flip_x: bool,
31    /// Explicit vertical flip.
32    pub flip_y: bool,
33    /// Mirror shorthand composed with the flip flags.
34    pub mirror: MirrorSource,
35    /// Horizontal transform origin; defaults to `50%`.
36    pub origin_x: Length,
37    /// Vertical transform origin; defaults to `50%`.
38    pub origin_y: Length,
39}
40
41impl Default for TransformSource {
42    fn default() -> Self {
43        Self {
44            translate_x: Length::Absolute(Unit::ZERO),
45            translate_y: Length::Absolute(Unit::ZERO),
46            rotate: 0,
47            scale_x: 1_000_000,
48            scale_y: 1_000_000,
49            flip_x: false,
50            flip_y: false,
51            mirror: MirrorSource::None,
52            origin_x: Length::Percent(500_000),
53            origin_y: Length::Percent(500_000),
54        }
55    }
56}
57
58/// Coordinate mirrored by [`TransformSource`].
59#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum MirrorSource {
62    /// No mirror shorthand.
63    #[default]
64    None,
65    /// Mirror horizontal coordinates.
66    Horizontal,
67    /// Mirror vertical coordinates.
68    Vertical,
69    /// Mirror both coordinates.
70    Both,
71}
72
73pub(crate) fn validate_transform(source: &TransformSource) -> Result<()> {
74    const MAX_SCALE: i64 = 100_000_000;
75    if source.scale_x == 0
76        || source.scale_y == 0
77        || source.scale_x.unsigned_abs() > MAX_SCALE as u64
78        || source.scale_y.unsigned_abs() > MAX_SCALE as u64
79        || matches!(source.origin_x, Length::Auto)
80        || matches!(source.origin_y, Length::Auto)
81        || matches!(source.translate_x, Length::Auto)
82        || matches!(source.translate_y, Length::Auto)
83    {
84        return Err(FileMakerError::new(
85            ErrorCode::SchemaField,
86            "transform requires non-zero bounded scales and explicit translations/origins",
87        ));
88    }
89    Ok(())
90}
91
92pub(crate) fn convert_transform(source: TransformSource) -> Result<TransformIr> {
93    let mirror_x = matches!(source.mirror, MirrorSource::Horizontal | MirrorSource::Both);
94    let mirror_y = matches!(source.mirror, MirrorSource::Vertical | MirrorSource::Both);
95    let scale_x = signed_scale(source.scale_x, source.flip_x ^ mirror_x, "horizontal")?;
96    let scale_y = signed_scale(source.scale_y, source.flip_y ^ mirror_y, "vertical")?;
97    Ok(TransformIr {
98        translate_x: source.translate_x,
99        translate_y: source.translate_y,
100        rotate: source.rotate,
101        scale_x,
102        scale_y,
103        origin_x: source.origin_x,
104        origin_y: source.origin_y,
105    })
106}
107
108fn signed_scale(scale: i64, flip: bool, axis: &str) -> Result<i64> {
109    if flip {
110        scale.checked_neg().ok_or_else(|| {
111            FileMakerError::new(ErrorCode::GeometryInvalid, format!("{axis} scale overflow"))
112        })
113    } else {
114        Ok(scale)
115    }
116}