Skip to main content

appcore_filemaker/
debug.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: debug.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 debug contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::debug_geometry::{mask_free_regions, selected_bounds};
16use crate::{
17    Color, ElementId, ErrorCode, FileMakerError, Point, Rect, ResolvedElement, ResolvedPage,
18    ResolvedScene, ResourceLimits, Result, SceneInspector, Size, Unit,
19};
20
21/// Geometry view used by a derived mask.
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MaskView {
25    /// Collision bounds.
26    #[default]
27    CollisionMask,
28    /// Layout bounds.
29    LayoutBounds,
30    /// Visual bounds.
31    VisualBounds,
32    /// All distinct bounds.
33    Combined,
34}
35
36/// Debug overlay switches. These never mutate a scene.
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38pub struct DebugOverlayOptions {
39    /// Grid spacing; common values are 1/5/10/20 logical units.
40    pub grid: Option<Unit>,
41    /// Draw coordinate rulers.
42    pub ruler: bool,
43    /// Draw IDs.
44    pub ids: bool,
45    /// Draw resolved origin coordinates.
46    pub coordinates: bool,
47    /// Draw bounds.
48    pub bounds: bool,
49    /// Label retained anchor expressions.
50    pub anchors: bool,
51    /// Draw named region rectangles.
52    pub regions: bool,
53    /// Draw the page safe-area rectangle.
54    pub safe_area: bool,
55    /// Draw collidable geometry and named exclusions.
56    pub collision: bool,
57    /// Draw a crosshair at each element origin.
58    pub crosshair: bool,
59    /// Bounds class.
60    pub view: MaskView,
61}
62
63/// Format-neutral debug primitive.
64#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(tag = "kind", rename_all = "snake_case")]
66pub enum DebugPrimitive {
67    /// Line segment.
68    Line {
69        /// Start.
70        from: Point,
71        /// End.
72        to: Point,
73        /// Stroke.
74        color: Color,
75    },
76    /// Rectangle outline.
77    Rect {
78        /// Bounds.
79        bounds: Rect,
80        /// Stroke.
81        color: Color,
82    },
83    /// Label.
84    Label {
85        /// Origin.
86        origin: Point,
87        /// UTF-8 label.
88        text: String,
89        /// Text color.
90        color: Color,
91    },
92}
93
94/// Derived overlay, separate from resolved scene elements.
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct DebugOverlay {
97    /// Page index.
98    pub page: usize,
99    /// Debug-only primitives.
100    pub primitives: Vec<DebugPrimitive>,
101}
102
103impl DebugOverlay {
104    /// Builds an overlay without modifying scene geometry or paint order.
105    pub fn build(
106        scene: &ResolvedScene,
107        page: usize,
108        options: &DebugOverlayOptions,
109    ) -> Result<Self> {
110        Self::build_bounded(scene, page, options, &ResourceLimits::default())
111    }
112
113    /// Builds an overlay under the caller's scene and diagnostic budgets.
114    pub fn build_bounded(
115        scene: &ResolvedScene,
116        page: usize,
117        options: &DebugOverlayOptions,
118        limits: &ResourceLimits,
119    ) -> Result<Self> {
120        crate::resolved::validate_scene_contract(scene, limits)?;
121        let page_ref = scene
122            .pages
123            .get(page)
124            .ok_or_else(|| debug_error("debug page was not found"))?;
125        if let Some(grid) = options.grid {
126            validate_grid(grid)?;
127        }
128        crate::debug_plan::validate_overlay(page_ref, options, limits)?;
129        let mut primitives = Vec::new();
130        if let Some(grid) = options.grid {
131            add_grid(&mut primitives, page_ref.size, grid)?;
132        }
133        add_page_geometry(&mut primitives, page_ref, options)?;
134        for element in &page_ref.elements {
135            add_element_geometry(&mut primitives, element, options)?;
136        }
137        if options.ruler {
138            add_ruler(
139                &mut primitives,
140                page_ref.size,
141                options.grid.unwrap_or(Unit::points(10)?),
142            )?;
143        }
144        Ok(Self { page, primitives })
145    }
146}
147
148fn add_page_geometry(
149    primitives: &mut Vec<DebugPrimitive>,
150    page: &ResolvedPage,
151    options: &DebugOverlayOptions,
152) -> Result<()> {
153    if options.safe_area {
154        if let Some(bounds) = page
155            .page_template
156            .as_ref()
157            .map(crate::PageTemplate::safe_bounds)
158            .transpose()?
159        {
160            add_named_rect(
161                primitives,
162                "safe",
163                bounds,
164                Color::Rgb { r: 0, g: 128, b: 0 },
165            );
166        }
167    }
168    if options.regions {
169        for region in &page.regions {
170            add_named_rect(
171                primitives,
172                &format!("region:{}", region.name),
173                region.bounds,
174                Color::Rgb {
175                    r: 0,
176                    g: 96,
177                    b: 192,
178                },
179            );
180        }
181    }
182    if options.collision {
183        for element in page.elements.iter().filter(|element| element.collidable) {
184            add_named_rect(
185                primitives,
186                &format!("collision:{}", element.id.as_str()),
187                element.bounds.collision,
188                Color::Rgb {
189                    r: 220,
190                    g: 0,
191                    b: 160,
192                },
193            );
194        }
195        for exclusion in &page.exclusions {
196            add_named_rect(
197                primitives,
198                &format!("exclusion:{}", exclusion.name),
199                exclusion.bounds,
200                Color::Rgb {
201                    r: 160,
202                    g: 0,
203                    b: 220,
204                },
205            );
206        }
207    }
208    Ok(())
209}
210
211fn add_element_geometry(
212    primitives: &mut Vec<DebugPrimitive>,
213    element: &ResolvedElement,
214    options: &DebugOverlayOptions,
215) -> Result<()> {
216    if options.bounds {
217        for bounds in selected_bounds(element.bounds, options.view) {
218            primitives.push(DebugPrimitive::Rect {
219                bounds,
220                color: Color::Rgba {
221                    r: 255,
222                    g: 0,
223                    b: 0,
224                    a: 160,
225                },
226            });
227        }
228    }
229    if options.ids {
230        primitives.push(DebugPrimitive::Label {
231            origin: element.bounds.layout.origin,
232            text: element.id.as_str().to_owned(),
233            color: Color::Rgb { r: 180, g: 0, b: 0 },
234        });
235    }
236    if options.coordinates {
237        primitives.push(DebugPrimitive::Label {
238            origin: element.bounds.layout.origin,
239            text: format!(
240                "({:.3}, {:.3}) pt",
241                element.bounds.layout.origin.x.as_points_f64(),
242                element.bounds.layout.origin.y.as_points_f64()
243            ),
244            color: Color::Rgb { r: 0, g: 0, b: 0 },
245        });
246    }
247    if options.anchors {
248        for (edge, expression) in &element.layout_trace.geometry.anchors {
249            primitives.push(DebugPrimitive::Label {
250                origin: element.bounds.layout.origin,
251                text: format!("anchor:{edge}={expression}"),
252                color: Color::Rgb {
253                    r: 128,
254                    g: 64,
255                    b: 0,
256                },
257            });
258        }
259    }
260    if options.crosshair {
261        add_crosshair(primitives, element.bounds.layout.origin)?;
262    }
263    Ok(())
264}
265
266fn add_grid(primitives: &mut Vec<DebugPrimitive>, size: Size, spacing: Unit) -> Result<()> {
267    let color = Color::Rgba {
268        r: 0,
269        g: 128,
270        b: 255,
271        a: 64,
272    };
273    let mut x = Unit::ZERO;
274    while x <= size.width {
275        primitives.push(DebugPrimitive::Line {
276            from: Point { x, y: Unit::ZERO },
277            to: Point { x, y: size.height },
278            color,
279        });
280        x = x.checked_add(spacing)?;
281    }
282    let mut y = Unit::ZERO;
283    while y <= size.height {
284        primitives.push(DebugPrimitive::Line {
285            from: Point { x: Unit::ZERO, y },
286            to: Point { x: size.width, y },
287            color,
288        });
289        y = y.checked_add(spacing)?;
290    }
291    Ok(())
292}
293
294fn validate_grid(grid: Unit) -> Result<()> {
295    for spacing in [1, 5, 10, 20] {
296        if grid == Unit::points(spacing)? {
297            return Ok(());
298        }
299    }
300    Err(debug_error("debug grid must be 1, 5, 10, or 20 points"))
301}
302
303fn add_ruler(primitives: &mut Vec<DebugPrimitive>, size: Size, spacing: Unit) -> Result<()> {
304    let mut x = Unit::ZERO;
305    while x <= size.width {
306        primitives.push(DebugPrimitive::Label {
307            origin: Point { x, y: Unit::ZERO },
308            text: format!("{:.3}", x.as_points_f64()),
309            color: Color::Rgb { r: 0, g: 0, b: 0 },
310        });
311        x = x.checked_add(spacing)?;
312    }
313    let mut y = Unit::ZERO;
314    while y <= size.height {
315        primitives.push(DebugPrimitive::Label {
316            origin: Point { x: Unit::ZERO, y },
317            text: format!("{:.3}", y.as_points_f64()),
318            color: Color::Rgb { r: 0, g: 0, b: 0 },
319        });
320        y = y.checked_add(spacing)?;
321    }
322    Ok(())
323}
324
325fn add_named_rect(primitives: &mut Vec<DebugPrimitive>, name: &str, bounds: Rect, color: Color) {
326    primitives.push(DebugPrimitive::Rect { bounds, color });
327    primitives.push(DebugPrimitive::Label {
328        origin: bounds.origin,
329        text: name.to_owned(),
330        color,
331    });
332}
333
334/// JSON-serializable geometry-derived mask.
335#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
336pub struct CollisionMask {
337    /// Page index.
338    pub page: usize,
339    /// Page size.
340    pub size: Size,
341    /// Selected occupied rectangles.
342    pub occupied: Vec<(ElementId, Rect)>,
343    /// Disjoint free rectangles.
344    pub free: Vec<Rect>,
345    /// Pairwise collisions.
346    pub collisions: Vec<(ElementId, ElementId, Rect)>,
347    /// Visual overflow IDs.
348    pub overflow: Vec<ElementId>,
349}
350
351impl CollisionMask {
352    /// Derives a mask from resolved geometry; bitmap pixels are never queried.
353    pub fn derive(scene: &ResolvedScene, page: usize, view: MaskView) -> Result<Self> {
354        Self::derive_bounded(scene, page, view, &ResourceLimits::default())
355    }
356
357    /// Derives a mask under the caller's scene and diagnostic geometry budgets.
358    pub fn derive_bounded(
359        scene: &ResolvedScene,
360        page: usize,
361        view: MaskView,
362        limits: &ResourceLimits,
363    ) -> Result<Self> {
364        crate::resolved::validate_scene_contract(scene, limits)?;
365        let page_ref = scene
366            .pages
367            .get(page)
368            .ok_or_else(|| debug_error("mask page was not found"))?;
369        let mut budget = crate::diagnostic_budget::DiagnosticBudget::new(limits)?;
370        let mut occupied = Vec::new();
371        for element in page_ref
372            .elements
373            .iter()
374            .filter(|element| view != MaskView::CollisionMask || element.collidable)
375        {
376            let mut element_bounds = Vec::new();
377            for bounds in selected_bounds(element.bounds, view) {
378                if !element_bounds.contains(&bounds) {
379                    budget.retained(occupied.len().saturating_add(1))?;
380                    occupied.push((element.id.clone(), bounds));
381                    element_bounds.push(bounds);
382                }
383            }
384        }
385        if matches!(view, MaskView::CollisionMask | MaskView::Combined) {
386            for exclusion in &page_ref.exclusions {
387                budget.retained(occupied.len().saturating_add(1))?;
388                occupied.push((
389                    ElementId::new(format!("exclusion.{}", exclusion.name))?,
390                    exclusion.bounds,
391                ));
392            }
393        }
394        let mut collisions = Vec::new();
395        for (index, (left_id, left)) in occupied.iter().enumerate() {
396            for (right_id, right) in &occupied[index + 1..] {
397                if left_id == right_id {
398                    continue;
399                }
400                budget.operation()?;
401                if let Some(overlap) = left.intersection(*right)? {
402                    budget.retained(collisions.len().saturating_add(1))?;
403                    collisions.push((left_id.clone(), right_id.clone(), overlap));
404                }
405            }
406        }
407        let free = mask_free_regions(page_ref.size, &occupied, &mut budget)?;
408        let overflow = SceneInspector::new(scene).inspect_page(page)?.overflow;
409        Ok(Self {
410            page,
411            size: page_ref.size,
412            occupied,
413            free,
414            collisions,
415            overflow,
416        })
417    }
418
419    /// Serializes stable geometry JSON.
420    pub fn to_json(&self) -> Result<Vec<u8>> {
421        self.to_json_bounded(&ResourceLimits::default())
422    }
423
424    /// Serializes stable geometry JSON under caller-supplied budgets.
425    pub fn to_json_bounded(&self, limits: &ResourceLimits) -> Result<Vec<u8>> {
426        self.validate_limits(limits)?;
427        let size = crate::memory::serialized_size_pretty(self)?;
428        if size > limits.max_output_bytes {
429            return Err(FileMakerError::new(
430                ErrorCode::LimitExceeded,
431                "debug mask JSON exceeds the output budget",
432            ));
433        }
434        let mut bytes = Vec::with_capacity(size);
435        serde_json::to_writer_pretty(&mut bytes, self)
436            .map_err(|error| debug_error(format!("cannot encode mask JSON: {error}")))?;
437        debug_assert_eq!(bytes.len(), size);
438        Ok(bytes)
439    }
440
441    pub(crate) fn validate_limits(&self, limits: &ResourceLimits) -> Result<()> {
442        limits.validate()?;
443        let retained = self
444            .occupied
445            .len()
446            .checked_add(self.free.len())
447            .and_then(|count| count.checked_add(self.collisions.len()))
448            .and_then(|count| count.checked_add(self.overflow.len()))
449            .ok_or_else(|| {
450                FileMakerError::new(ErrorCode::LimitExceeded, "debug mask count overflow")
451            })?;
452        crate::diagnostic_budget::DiagnosticBudget::new(limits)?.retained(retained)
453    }
454}
455
456fn add_crosshair(primitives: &mut Vec<DebugPrimitive>, origin: Point) -> Result<()> {
457    let arm = Unit::points(3)?;
458    primitives.push(DebugPrimitive::Line {
459        from: Point {
460            x: origin.x.checked_sub(arm)?,
461            y: origin.y,
462        },
463        to: Point {
464            x: origin.x.checked_add(arm)?,
465            y: origin.y,
466        },
467        color: Color::Rgb {
468            r: 255,
469            g: 0,
470            b: 255,
471        },
472    });
473    primitives.push(DebugPrimitive::Line {
474        from: Point {
475            x: origin.x,
476            y: origin.y.checked_sub(arm)?,
477        },
478        to: Point {
479            x: origin.x,
480            y: origin.y.checked_add(arm)?,
481        },
482        color: Color::Rgb {
483            r: 255,
484            g: 0,
485            b: 255,
486        },
487    });
488    Ok(())
489}
490
491fn debug_error(message: impl Into<String>) -> FileMakerError {
492    FileMakerError::new(ErrorCode::LayoutInvalid, message)
493}