Skip to main content

appcore_filemaker/
collision.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: collision.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 collision contracts and behavior for this crate.
12
13use std::collections::BTreeSet;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{Rect, Result};
18
19/// Which resolved box participates in collision.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum CollisionBounds {
23    /// Layout box.
24    #[default]
25    Layout,
26    /// Visual box including stroke/effects.
27    Visual,
28    /// Intrinsic content box.
29    Intrinsic,
30}
31
32/// Resolution applied when geometry overlaps.
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum CollisionResolution {
36    /// Move the lower-priority movable node forward in flow.
37    #[default]
38    Push,
39    /// Reject layout.
40    Error,
41    /// Accept overlap explicitly.
42    Overlay,
43    /// Move the candidate to the next page.
44    NextPage,
45    /// Reduce the candidate within its minimum bounds.
46    Shrink,
47}
48
49/// Inheritable collision policy.
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51pub struct CollisionPolicy {
52    /// Whether geometry participates.
53    pub enabled: bool,
54    /// Collision group.
55    pub group: String,
56    /// Groups collided with; empty means every group.
57    pub collides_with: BTreeSet<String>,
58    /// IDs ignored explicitly.
59    pub ignore: BTreeSet<String>,
60    /// Higher value wins movement conflict.
61    pub priority: i32,
62    /// Whether the resolver may reposition the node.
63    pub movable: bool,
64    /// Selected resolved box.
65    pub bounds: CollisionBounds,
66    /// Overlap resolution.
67    pub resolution: CollisionResolution,
68}
69
70impl Default for CollisionPolicy {
71    fn default() -> Self {
72        Self {
73            enabled: true,
74            group: "default".to_owned(),
75            collides_with: BTreeSet::new(),
76            ignore: BTreeSet::new(),
77            priority: 0,
78            movable: true,
79            bounds: CollisionBounds::Layout,
80            resolution: CollisionResolution::Push,
81        }
82    }
83}
84
85/// Indexed collision rule and geometry.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct CollisionRule {
88    /// Stable element ID.
89    pub id: String,
90    /// Page-local collision bounds.
91    pub bounds: Rect,
92    /// Effective policy.
93    pub policy: CollisionPolicy,
94    /// Stable insertion sequence.
95    pub sequence: usize,
96}
97
98impl CollisionRule {
99    /// Whether this rule can collide with another effective rule.
100    #[must_use]
101    pub fn applies_to(&self, other: &Self) -> bool {
102        self.policy.enabled
103            && other.policy.enabled
104            && !self.policy.ignore.contains(&other.id)
105            && !other.policy.ignore.contains(&self.id)
106            && (self.policy.collides_with.is_empty()
107                || self.policy.collides_with.contains(&other.policy.group))
108            && (other.policy.collides_with.is_empty()
109                || other.policy.collides_with.contains(&self.policy.group))
110    }
111}
112
113/// Deterministic spatial query contract.
114pub trait SpatialIndex {
115    /// Inserts one resolved rule.
116    fn insert(&mut self, rule: CollisionRule) -> Result<()>;
117    /// Returns overlapping rules in stable insertion order.
118    fn query(&self, bounds: Rect) -> Result<Vec<&CollisionRule>>;
119    /// Number of indexed rules.
120    fn len(&self) -> usize;
121    /// Whether no rules are indexed.
122    fn is_empty(&self) -> bool {
123        self.len() == 0
124    }
125}
126
127/// Simple deterministic linear index; suitable baseline and test oracle.
128#[derive(Clone, Debug, Default)]
129pub struct LinearSpatialIndex {
130    rules: Vec<CollisionRule>,
131}
132
133impl SpatialIndex for LinearSpatialIndex {
134    fn insert(&mut self, rule: CollisionRule) -> Result<()> {
135        self.rules.push(rule);
136        self.rules.sort_by_key(|entry| entry.sequence);
137        Ok(())
138    }
139
140    fn query(&self, bounds: Rect) -> Result<Vec<&CollisionRule>> {
141        self.rules
142            .iter()
143            .filter_map(|rule| match rule.bounds.intersects(bounds) {
144                Ok(true) => Some(Ok(rule)),
145                Ok(false) => None,
146                Err(error) => Some(Err(error)),
147            })
148            .collect()
149    }
150
151    fn len(&self) -> usize {
152        self.rules.len()
153    }
154}
155
156impl LinearSpatialIndex {
157    pub(crate) fn first_applicable(
158        &self,
159        candidate: &CollisionRule,
160        comparisons: &mut usize,
161        maximum: usize,
162    ) -> Result<Option<&CollisionRule>> {
163        for rule in &self.rules {
164            *comparisons = comparisons.checked_add(1).ok_or_else(|| {
165                crate::FileMakerError::new(
166                    crate::ErrorCode::LimitExceeded,
167                    "layout collision comparison count overflow",
168                )
169            })?;
170            if *comparisons > maximum {
171                return Err(crate::FileMakerError::new(
172                    crate::ErrorCode::LimitExceeded,
173                    "layout collision comparison budget exhausted",
174                ));
175            }
176            if candidate.applies_to(rule) && rule.bounds.intersects(candidate.bounds)? {
177                return Ok(Some(rule));
178            }
179        }
180        Ok(None)
181    }
182}