Skip to main content

appcore_filemaker/
geometry.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: geometry.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
11//! Defines bounded geometry contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::transform_math::{fixed_sin_cos, matrix_term};
16use crate::{ErrorCode, FileMakerError, Result, Unit};
17
18/// A point in resolved page coordinates.
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20pub struct Point {
21    /// Horizontal coordinate.
22    pub x: Unit,
23    /// Vertical coordinate.
24    pub y: Unit,
25}
26
27/// A non-negative resolved size.
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
29pub struct Size {
30    /// Width.
31    pub width: Unit,
32    /// Height.
33    pub height: Unit,
34}
35
36impl Size {
37    /// Creates a size after rejecting negative dimensions.
38    pub fn new(width: Unit, height: Unit) -> Result<Self> {
39        if width < Unit::ZERO || height < Unit::ZERO {
40            return Err(invalid_geometry("size dimensions cannot be negative"));
41        }
42        Ok(Self { width, height })
43    }
44}
45
46/// Axis-aligned resolved rectangle.
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
48pub struct Rect {
49    /// Top-left origin.
50    pub origin: Point,
51    /// Non-negative size.
52    pub size: Size,
53}
54
55impl Rect {
56    /// Creates a rectangle after validating dimensions.
57    pub fn new(x: Unit, y: Unit, width: Unit, height: Unit) -> Result<Self> {
58        Ok(Self {
59            origin: Point { x, y },
60            size: Size::new(width, height)?,
61        })
62    }
63
64    /// Checked right edge.
65    pub fn right(self) -> Result<Unit> {
66        self.origin.x.checked_add(self.size.width)
67    }
68
69    /// Checked bottom edge.
70    pub fn bottom(self) -> Result<Unit> {
71        self.origin.y.checked_add(self.size.height)
72    }
73
74    /// Whether rectangles overlap with positive area.
75    pub fn intersects(self, other: Self) -> Result<bool> {
76        Ok(self.origin.x < other.right()?
77            && self.right()? > other.origin.x
78            && self.origin.y < other.bottom()?
79            && self.bottom()? > other.origin.y)
80    }
81
82    /// Intersection rectangle, when positive-area overlap exists.
83    pub fn intersection(self, other: Self) -> Result<Option<Self>> {
84        if !self.intersects(other)? {
85            return Ok(None);
86        }
87        let x = self.origin.x.max(other.origin.x);
88        let y = self.origin.y.max(other.origin.y);
89        let right = self.right()?.min(other.right()?);
90        let bottom = self.bottom()?.min(other.bottom()?);
91        Ok(Some(Self::new(
92            x,
93            y,
94            right.checked_sub(x)?,
95            bottom.checked_sub(y)?,
96        )?))
97    }
98
99    /// Smallest rectangle containing both inputs.
100    pub fn union(self, other: Self) -> Result<Self> {
101        let x = self.origin.x.min(other.origin.x);
102        let y = self.origin.y.min(other.origin.y);
103        let right = self.right()?.max(other.right()?);
104        let bottom = self.bottom()?.max(other.bottom()?);
105        Self::new(x, y, right.checked_sub(x)?, bottom.checked_sub(y)?)
106    }
107
108    /// Returns whether the point lies in the half-open rectangle.
109    pub fn contains(self, point: Point) -> Result<bool> {
110        Ok(point.x >= self.origin.x
111            && point.x < self.right()?
112            && point.y >= self.origin.y
113            && point.y < self.bottom()?)
114    }
115}
116
117/// Insets around a rectangle.
118#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
119pub struct Insets {
120    /// Top inset.
121    pub top: Unit,
122    /// Right inset.
123    pub right: Unit,
124    /// Bottom inset.
125    pub bottom: Unit,
126    /// Left inset.
127    pub left: Unit,
128}
129
130/// Fixed-point affine transform using millionth scale coefficients.
131#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
132pub struct Transform {
133    /// Horizontal scale/rotation coefficient.
134    pub a: i64,
135    /// Vertical shear/rotation coefficient.
136    pub b: i64,
137    /// Horizontal shear/rotation coefficient.
138    pub c: i64,
139    /// Vertical scale/rotation coefficient.
140    pub d: i64,
141    /// Horizontal translation.
142    pub tx: Unit,
143    /// Vertical translation.
144    pub ty: Unit,
145}
146
147/// Fully resolved vector path command in page coordinates.
148#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
149#[serde(tag = "command", rename_all = "snake_case")]
150pub enum PathCommand {
151    /// Starts a contour.
152    Move { to: Point },
153    /// Adds a straight segment.
154    Line { to: Point },
155    /// Adds a cubic Bézier segment.
156    Curve {
157        control_1: Point,
158        control_2: Point,
159        to: Point,
160    },
161    /// Closes the current contour.
162    Close,
163}
164
165impl Default for Transform {
166    fn default() -> Self {
167        Self::IDENTITY
168    }
169}
170
171impl Transform {
172    /// Identity transform.
173    pub const IDENTITY: Self = Self {
174        a: 1_000_000,
175        b: 0,
176        c: 0,
177        d: 1_000_000,
178        tx: Unit::ZERO,
179        ty: Unit::ZERO,
180    };
181
182    /// Creates a checked page-space translation.
183    #[must_use]
184    pub const fn translation(tx: Unit, ty: Unit) -> Self {
185        Self {
186            tx,
187            ty,
188            ..Self::IDENTITY
189        }
190    }
191
192    /// Creates a fixed-point scale. Negative coefficients mirror an axis.
193    pub fn scale(x: i64, y: i64) -> Result<Self> {
194        if x == 0 || y == 0 {
195            return Err(invalid_geometry("transform scale cannot collapse an axis"));
196        }
197        Ok(Self {
198            a: x,
199            b: 0,
200            c: 0,
201            d: y,
202            tx: Unit::ZERO,
203            ty: Unit::ZERO,
204        })
205    }
206
207    /// Creates an integer-degree rotation quantized to millionth coefficients.
208    pub fn rotation_degrees(degrees: i32) -> Result<Self> {
209        let (sin, cos) = fixed_sin_cos(degrees)?;
210        Ok(Self {
211            a: cos,
212            b: sin,
213            c: sin
214                .checked_neg()
215                .ok_or_else(|| invalid_geometry("rotation overflow"))?,
216            d: cos,
217            tx: Unit::ZERO,
218            ty: Unit::ZERO,
219        })
220    }
221
222    /// Applies `self`, then `next`, using checked fixed-point matrix composition.
223    pub fn then(self, next: Self) -> Result<Self> {
224        Ok(Self {
225            a: matrix_term(next.a, self.a, next.c, self.b)?,
226            b: matrix_term(next.b, self.a, next.d, self.b)?,
227            c: matrix_term(next.a, self.c, next.c, self.d)?,
228            d: matrix_term(next.b, self.c, next.d, self.d)?,
229            tx: combine(self.tx, next.a, self.ty, next.c)?.checked_add(next.tx)?,
230            ty: combine(self.tx, next.b, self.ty, next.d)?.checked_add(next.ty)?,
231        })
232    }
233
234    /// Applies this linear transform around an explicit page-space origin.
235    pub fn around(self, origin: Point) -> Result<Self> {
236        Self::translation(
237            Unit::from_raw(
238                origin
239                    .x
240                    .raw()
241                    .checked_neg()
242                    .ok_or_else(|| invalid_geometry("transform origin negation overflow"))?,
243            ),
244            Unit::from_raw(
245                origin
246                    .y
247                    .raw()
248                    .checked_neg()
249                    .ok_or_else(|| invalid_geometry("transform origin negation overflow"))?,
250            ),
251        )
252        .then(self)?
253        .then(Self::translation(origin.x, origin.y))
254    }
255
256    /// Whether the transform leaves page coordinates unchanged.
257    #[must_use]
258    pub const fn is_identity(self) -> bool {
259        self.a == Self::IDENTITY.a
260            && self.b == 0
261            && self.c == 0
262            && self.d == Self::IDENTITY.d
263            && self.tx.raw() == 0
264            && self.ty.raw() == 0
265    }
266
267    /// Maps a page-space displacement back through the linear matrix.
268    pub fn inverse_vector(self, vector: Point) -> Result<Point> {
269        let determinant =
270            i128::from(self.a) * i128::from(self.d) - i128::from(self.b) * i128::from(self.c);
271        if determinant == 0 {
272            return Err(invalid_geometry("transform matrix is not invertible"));
273        }
274        let x = (i128::from(self.d) * i128::from(vector.x.raw())
275            - i128::from(self.c) * i128::from(vector.y.raw()))
276        .checked_mul(1_000_000)
277        .ok_or_else(|| invalid_geometry("inverse transform overflow"))?;
278        let y = (i128::from(self.a) * i128::from(vector.y.raw())
279            - i128::from(self.b) * i128::from(vector.x.raw()))
280        .checked_mul(1_000_000)
281        .ok_or_else(|| invalid_geometry("inverse transform overflow"))?;
282        Ok(Point {
283            x: Unit::from_raw(divide_round_i128(x, determinant)?),
284            y: Unit::from_raw(divide_round_i128(y, determinant)?),
285        })
286    }
287
288    /// Transforms a point with checked fixed-point arithmetic.
289    pub fn apply(self, point: Point) -> Result<Point> {
290        let x = combine(point.x, self.a, point.y, self.c)?.checked_add(self.tx)?;
291        let y = combine(point.x, self.b, point.y, self.d)?.checked_add(self.ty)?;
292        Ok(Point { x, y })
293    }
294
295    /// Returns the axis-aligned bounds of a transformed rectangle.
296    pub fn bounds(self, rect: Rect) -> Result<Rect> {
297        let right = rect.right()?;
298        let bottom = rect.bottom()?;
299        let points = [
300            self.apply(rect.origin)?,
301            self.apply(Point {
302                x: right,
303                y: rect.origin.y,
304            })?,
305            self.apply(Point {
306                x: rect.origin.x,
307                y: bottom,
308            })?,
309            self.apply(Point {
310                x: right,
311                y: bottom,
312            })?,
313        ];
314        let min_x = points
315            .iter()
316            .map(|point| point.x)
317            .min()
318            .unwrap_or(Unit::ZERO);
319        let max_x = points
320            .iter()
321            .map(|point| point.x)
322            .max()
323            .unwrap_or(Unit::ZERO);
324        let min_y = points
325            .iter()
326            .map(|point| point.y)
327            .min()
328            .unwrap_or(Unit::ZERO);
329        let max_y = points
330            .iter()
331            .map(|point| point.y)
332            .max()
333            .unwrap_or(Unit::ZERO);
334        Rect::new(
335            min_x,
336            min_y,
337            max_x.checked_sub(min_x)?,
338            max_y.checked_sub(min_y)?,
339        )
340    }
341}
342
343/// Geometry used for collision and vector output.
344#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
345#[serde(tag = "kind", rename_all = "snake_case")]
346pub enum Shape {
347    /// Axis-aligned rectangle.
348    Rect {
349        /// Rectangle bounds.
350        bounds: Rect,
351    },
352    /// Ellipse contained in bounds.
353    Ellipse {
354        /// Ellipse bounds.
355        bounds: Rect,
356    },
357    /// Closed polygon.
358    Polygon {
359        /// Polygon vertices.
360        points: Vec<Point>,
361    },
362    /// Simple polyline/path bounds retained with commands elsewhere.
363    Path {
364        /// Conservative collision bounds.
365        bounds: Rect,
366        /// Resolved commands preserved for vector and raster exporters.
367        commands: Vec<PathCommand>,
368    },
369}
370
371impl Shape {
372    /// Returns conservative axis-aligned collision bounds.
373    pub fn bounds(&self) -> Result<Rect> {
374        match self {
375            Self::Rect { bounds } | Self::Ellipse { bounds } | Self::Path { bounds, .. } => {
376                Ok(*bounds)
377            }
378            Self::Polygon { points } => polygon_bounds(points),
379        }
380    }
381}
382
383/// Distinct geometry bounds retained after measurement and layout.
384#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
385pub struct BoundsSet {
386    /// Content's natural measurement.
387    pub intrinsic: Rect,
388    /// Box participating in layout.
389    pub layout: Rect,
390    /// Geometry used by collision policies.
391    pub collision: Rect,
392    /// Geometry actually painted.
393    pub visual: Rect,
394    /// Optional clipping rectangle.
395    pub clip: Option<Rect>,
396}
397
398fn combine(first: Unit, first_scale: i64, second: Unit, second_scale: i64) -> Result<Unit> {
399    let left = i128::from(first.raw()) * i128::from(first_scale);
400    let right = i128::from(second.raw()) * i128::from(second_scale);
401    let raw = (left + right) / 1_000_000;
402    i64::try_from(raw)
403        .map(Unit::from_raw)
404        .map_err(|_| invalid_geometry("transform overflow"))
405}
406
407fn divide_round_i128(numerator: i128, denominator: i128) -> Result<i64> {
408    let (numerator, denominator) = if denominator < 0 {
409        (
410            numerator
411                .checked_neg()
412                .ok_or_else(|| invalid_geometry("inverse transform overflow"))?,
413            denominator
414                .checked_neg()
415                .ok_or_else(|| invalid_geometry("inverse transform overflow"))?,
416        )
417    } else {
418        (numerator, denominator)
419    };
420    let adjustment = denominator / 2;
421    let adjusted = if numerator >= 0 {
422        numerator.checked_add(adjustment)
423    } else {
424        numerator.checked_sub(adjustment)
425    }
426    .ok_or_else(|| invalid_geometry("inverse transform rounding overflow"))?;
427    i64::try_from(adjusted / denominator)
428        .map_err(|_| invalid_geometry("inverse transform is outside the supported range"))
429}
430
431fn polygon_bounds(points: &[Point]) -> Result<Rect> {
432    let first = points
433        .first()
434        .ok_or_else(|| invalid_geometry("polygon requires at least one point"))?;
435    let (mut min_x, mut max_x, mut min_y, mut max_y) = (first.x, first.x, first.y, first.y);
436    for point in &points[1..] {
437        min_x = min_x.min(point.x);
438        max_x = max_x.max(point.x);
439        min_y = min_y.min(point.y);
440        max_y = max_y.max(point.y);
441    }
442    Rect::new(
443        min_x,
444        min_y,
445        max_x.checked_sub(min_x)?,
446        max_y.checked_sub(min_y)?,
447    )
448}
449
450fn invalid_geometry(message: impl Into<String>) -> FileMakerError {
451    FileMakerError::new(ErrorCode::GeometryInvalid, message)
452}