Skip to main content

appcore_filemaker/
inspect.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: inspect.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 inspect contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::{
16    BoundsSet, ElementId, ErrorCode, FileMakerError, LayoutTrace, Provenance, Rect,
17    ResolvedElement, ResolvedPage, ResolvedScene, ResourceLimits, Result, Unit,
18};
19
20/// Read-only element inspection response.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22pub struct ElementInspection {
23    /// Page containing the element.
24    pub page: usize,
25    /// Stable ID.
26    pub id: ElementId,
27    /// Every distinct bounds class.
28    pub bounds: BoundsSet,
29    /// Whether the element participates in collision geometry.
30    pub collidable: bool,
31    /// Visual layer.
32    pub layer: String,
33    /// Visual z index.
34    pub z_index: i32,
35    /// Source and patch provenance.
36    pub provenance: Provenance,
37    /// Source geometry and resolved collision/reflow inputs.
38    pub layout_trace: LayoutTrace,
39    /// Table fragment index when this element is a paginated table.
40    pub table_fragment: Option<usize>,
41    /// Number of rows retained in this table fragment.
42    pub table_rows: Option<usize>,
43}
44
45/// Read-only page summary.
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub struct PageInspection {
48    /// Page index.
49    pub page: usize,
50    /// Semantic physical-page role.
51    pub role: crate::PageRole,
52    /// Stable names of non-painted exclusion geometry.
53    pub exclusions: Vec<String>,
54    /// Stable names of resolved layout regions.
55    pub regions: Vec<String>,
56    /// Resolved safe area, when page metadata exists.
57    pub safe: Option<Rect>,
58    /// Number of resolved elements.
59    pub elements: usize,
60    /// Union of layout bounds, when non-empty.
61    pub occupied: Option<Rect>,
62    /// Elements overflowing page bounds.
63    pub overflow: Vec<ElementId>,
64}
65
66/// Human/tool-readable explanation of a resolved placement.
67#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
68pub struct LayoutExplanation {
69    /// Stable ID.
70    pub id: ElementId,
71    /// Final page.
72    pub page: usize,
73    /// Final geometry.
74    pub bounds: BoundsSet,
75    /// Whether intrinsic measurement differs from layout.
76    pub measured: bool,
77    /// Number of runtime patches contributing to the result.
78    pub patch_count: usize,
79    /// Component expansion chain.
80    pub components: Vec<String>,
81    /// Logical source.
82    pub source: String,
83    /// Concise deterministic reasoning steps.
84    pub decisions: Vec<String>,
85    /// Structured source geometry and collision/reflow inputs.
86    pub trace: LayoutTrace,
87}
88
89/// Immutable scene inspection facade.
90pub struct SceneInspector<'a> {
91    scene: &'a ResolvedScene,
92}
93
94impl<'a> SceneInspector<'a> {
95    /// Creates a read-only inspector.
96    #[must_use]
97    pub const fn new(scene: &'a ResolvedScene) -> Self {
98        Self { scene }
99    }
100
101    /// Finds an element by exact ID.
102    pub fn inspect_element(&self, id: &ElementId) -> Result<ElementInspection> {
103        let (page, element) = self.find(id)?;
104        Ok(ElementInspection {
105            page,
106            id: element.id.clone(),
107            bounds: element.bounds,
108            collidable: element.collidable,
109            layer: element.layer.clone(),
110            z_index: element.z_index,
111            provenance: element.provenance.clone(),
112            layout_trace: element.layout_trace.clone(),
113            table_fragment: element.table.as_ref().map(|table| table.index),
114            table_rows: element.table.as_ref().map(|table| table.rows.len()),
115        })
116    }
117
118    /// Summarizes a page and reports overflow.
119    pub fn inspect_page(&self, page: usize) -> Result<PageInspection> {
120        let page_ref = self.page(page)?;
121        let page_bounds = Rect::new(
122            Unit::ZERO,
123            Unit::ZERO,
124            page_ref.size.width,
125            page_ref.size.height,
126        )?;
127        let mut occupied: Option<Rect> = None;
128        let mut overflow = Vec::new();
129        for element in &page_ref.elements {
130            occupied = Some(match occupied {
131                Some(bounds) => bounds.union(element.bounds.layout)?,
132                None => element.bounds.layout,
133            });
134            if !contains_rect(page_bounds, element.bounds.visual)? {
135                overflow.push(element.id.clone());
136            }
137        }
138        Ok(PageInspection {
139            page,
140            role: page_ref.role,
141            exclusions: page_ref
142                .exclusions
143                .iter()
144                .map(|exclusion| exclusion.name.clone())
145                .collect(),
146            regions: page_ref
147                .regions
148                .iter()
149                .map(|region| region.name.clone())
150                .collect(),
151            safe: page_ref
152                .page_template
153                .as_ref()
154                .map(crate::PageTemplate::safe_bounds)
155                .transpose()?,
156            elements: page_ref.elements.len(),
157            occupied,
158            overflow,
159        })
160    }
161
162    /// Explains final geometry, measurement, page assignment, and provenance.
163    pub fn explain_layout(&self, id: &ElementId) -> Result<LayoutExplanation> {
164        let (page, element) = self.find(id)?;
165        let mut decisions = vec![format!(
166            "layout=({}, {}, {}, {})",
167            element.bounds.layout.origin.x.raw(),
168            element.bounds.layout.origin.y.raw(),
169            element.bounds.layout.size.width.raw(),
170            element.bounds.layout.size.height.raw()
171        )];
172        if element.bounds.intrinsic != element.bounds.layout {
173            decisions.push(format!(
174                "measurement intrinsic=({}, {}, {}, {}) constrained by layout",
175                element.bounds.intrinsic.origin.x.raw(),
176                element.bounds.intrinsic.origin.y.raw(),
177                element.bounds.intrinsic.size.width.raw(),
178                element.bounds.intrinsic.size.height.raw()
179            ));
180        } else {
181            decisions.push("measurement matched proposed layout".to_owned());
182        }
183        decisions.push(format!(
184            "source x={:?} y={:?} width={:?} height={:?} region={:?}",
185            element.layout_trace.geometry.x,
186            element.layout_trace.geometry.y,
187            element.layout_trace.geometry.width,
188            element.layout_trace.geometry.height,
189            element.layout_trace.geometry.region
190        ));
191        if !element.layout_trace.geometry.anchors.is_empty() {
192            decisions.push(format!(
193                "anchors={:?}",
194                element.layout_trace.geometry.anchors
195            ));
196        }
197        decisions.push(format!(
198            "collision enabled={} bounds={:?} policy={:?} reflowed={}",
199            element.layout_trace.collision_policy.enabled,
200            element.layout_trace.collision_policy.bounds,
201            element.layout_trace.collision_policy.resolution,
202            element.layout_trace.reflowed
203        ));
204        if element.layout_trace.initial_page != page {
205            decisions.push(format!(
206                "page/reflow moved placement from page {} to page {}",
207                element.layout_trace.initial_page + 1,
208                page + 1
209            ));
210        } else {
211            decisions.push(format!("page/reflow retained page {}", page + 1));
212        }
213        if let Some(table) = &element.table {
214            decisions.push(format!(
215                "table fragment {} contains {} rows",
216                table.index,
217                table.rows.len()
218            ));
219        }
220        Ok(LayoutExplanation {
221            id: element.id.clone(),
222            page,
223            bounds: element.bounds,
224            measured: element.bounds.intrinsic != element.bounds.layout,
225            patch_count: element.provenance.patches.len(),
226            components: element.provenance.components.clone(),
227            source: element.provenance.source.clone(),
228            decisions,
229            trace: element.layout_trace.clone(),
230        })
231    }
232
233    /// Returns disjoint rectangular free regions after subtracting layout bounds.
234    pub fn query_free_regions(&self, page: usize, minimum: crate::Size) -> Result<Vec<Rect>> {
235        self.query_free_regions_bounded(page, minimum, &ResourceLimits::default())
236    }
237
238    /// Returns free regions under the caller's diagnostic geometry budget.
239    pub fn query_free_regions_bounded(
240        &self,
241        page: usize,
242        minimum: crate::Size,
243        limits: &ResourceLimits,
244    ) -> Result<Vec<Rect>> {
245        crate::resolved::validate_scene_contract(self.scene, limits)?;
246        let page_ref = self.page(page)?;
247        let mut budget = crate::diagnostic_budget::DiagnosticBudget::new(limits)?;
248        let mut free = vec![Rect::new(
249            Unit::ZERO,
250            Unit::ZERO,
251            page_ref.size.width,
252            page_ref.size.height,
253        )?];
254        for element in page_ref
255            .elements
256            .iter()
257            .filter(|element| element.collidable)
258        {
259            let mut next = Vec::new();
260            for region in free {
261                budget.operation()?;
262                let pieces = subtract(region, element.bounds.collision)?;
263                budget.retained(next.len().saturating_add(pieces.len()))?;
264                next.extend(pieces);
265            }
266            free = next;
267        }
268        for exclusion in &page_ref.exclusions {
269            let mut next = Vec::new();
270            for region in free {
271                budget.operation()?;
272                let pieces = subtract(region, exclusion.bounds)?;
273                budget.retained(next.len().saturating_add(pieces.len()))?;
274                next.extend(pieces);
275            }
276            free = next;
277        }
278        free.retain(|region| {
279            region.size.width >= minimum.width && region.size.height >= minimum.height
280        });
281        free.sort_by_key(|region| {
282            (
283                region.origin.y,
284                region.origin.x,
285                region.size.height,
286                region.size.width,
287            )
288        });
289        Ok(free)
290    }
291
292    fn find(&self, id: &ElementId) -> Result<(usize, &ResolvedElement)> {
293        self.scene
294            .pages
295            .iter()
296            .find_map(|page| {
297                page.elements
298                    .iter()
299                    .find(|element| &element.id == id)
300                    .map(|element| (page.index, element))
301            })
302            .ok_or_else(|| inspect_error(format!("element `{}` was not found", id.as_str())))
303    }
304
305    fn page(&self, index: usize) -> Result<&ResolvedPage> {
306        self.scene
307            .pages
308            .get(index)
309            .ok_or_else(|| inspect_error(format!("page {index} was not found")))
310    }
311}
312
313pub(crate) fn subtract(region: Rect, occupied: Rect) -> Result<Vec<Rect>> {
314    let Some(overlap) = region.intersection(occupied)? else {
315        return Ok(vec![region]);
316    };
317    let mut result = Vec::with_capacity(4);
318    if overlap.origin.y > region.origin.y {
319        result.push(Rect::new(
320            region.origin.x,
321            region.origin.y,
322            region.size.width,
323            overlap.origin.y.checked_sub(region.origin.y)?,
324        )?);
325    }
326    if overlap.bottom()? < region.bottom()? {
327        result.push(Rect::new(
328            region.origin.x,
329            overlap.bottom()?,
330            region.size.width,
331            region.bottom()?.checked_sub(overlap.bottom()?)?,
332        )?);
333    }
334    if overlap.origin.x > region.origin.x {
335        result.push(Rect::new(
336            region.origin.x,
337            overlap.origin.y,
338            overlap.origin.x.checked_sub(region.origin.x)?,
339            overlap.size.height,
340        )?);
341    }
342    if overlap.right()? < region.right()? {
343        result.push(Rect::new(
344            overlap.right()?,
345            overlap.origin.y,
346            region.right()?.checked_sub(overlap.right()?)?,
347            overlap.size.height,
348        )?);
349    }
350    Ok(result)
351}
352
353fn contains_rect(outer: Rect, inner: Rect) -> Result<bool> {
354    Ok(inner.origin.x >= outer.origin.x
355        && inner.origin.y >= outer.origin.y
356        && inner.right()? <= outer.right()?
357        && inner.bottom()? <= outer.bottom()?)
358}
359
360fn inspect_error(message: impl Into<String>) -> FileMakerError {
361    FileMakerError::new(ErrorCode::LayoutInvalid, message)
362}