orrery-core 0.1.0

Core types and definitions for Orrery diagrams
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
//! Shape definitions and rendering traits.
//!
//! This module provides the [`ShapeDefinition`] trait and [`Shape`] wrapper
//! for rendering diagram node shapes (rectangles, ovals, actors, etc.).

use std::rc::Rc;

use crate::{
    color::Color,
    draw::{
        Drawable, LayeredOutput, RenderLayer, StrokeDefinition, TextDefinition,
        text_positioning::TextPositioningStrategy,
    },
    geometry::{Insets, Point, Size},
};

mod actor;
mod boundary;
mod component;
mod control;
mod entity;
mod interface;
mod oval;
mod rectangle;

pub use actor::ActorDefinition;
pub use boundary::BoundaryDefinition;
pub use component::ComponentDefinition;
pub use control::ControlDefinition;
pub use entity::EntityDefinition;
pub use interface::InterfaceDefinition;
pub use oval::OvalDefinition;
pub use rectangle::RectangleDefinition;

/// A trait for shape definitions that provide stateless calculations.
pub trait ShapeDefinition: std::fmt::Debug {
    /// Returns true if this shape supports containing content
    /// Default implementation returns false for safety
    fn supports_content(&self) -> bool {
        false
    }
    /// Find the intersection point where a line from point a to point b intersects with this shape
    /// centered at point a with the given size
    fn find_intersection(&self, a: Point, b: Point, a_size: Size) -> Point {
        find_rectangle_intersection(a, b, a_size)
    }

    /// Calculate the inner shape size needed to contain the given content size with padding.
    /// This is the size of the shape boundary excluding the stroke.
    /// For content-free shapes, content_size and padding may be ignored.
    fn calculate_inner_size(&self, content_size: Size, padding: Insets) -> Size;

    /// Calculate the outer shape size including the stroke.
    /// By default, this adds the stroke width to the inner size in both dimensions.
    fn calculate_outer_size(&self, content_size: Size, padding: Insets) -> Size {
        let inner_size = self.calculate_inner_size(content_size, padding);
        let stroke_width = self.stroke().width();
        Size::new(
            inner_size.width() + stroke_width,
            inner_size.height() + stroke_width,
        )
    }

    /// Renders this shape to an SVG node element.
    ///
    /// # Arguments
    ///
    /// * `size` - The dimensions of the shape to render.
    /// * `position` - The center position of the shape.
    ///
    /// # Returns
    ///
    /// A boxed SVG node representing the rendered shape.
    fn render_to_svg(&self, size: Size, position: Point) -> Box<dyn svg::Node>;

    /// Creates a boxed clone of this shape definition.
    fn clone_box(&self) -> Box<dyn ShapeDefinition>;

    /// Set the fill color for the rectangle
    fn set_fill_color(&mut self, _color: Option<Color>) -> Result<(), &'static str> {
        Err("fill_color is not supported for this shape")
    }

    /// Set the corner rounding for the rectangle
    fn set_rounded(&mut self, _radius: usize) -> Result<(), &'static str> {
        Err("rounded corners are not supported for this shape")
    }

    /// Get the stroke definition for the shape.
    fn stroke(&self) -> &Rc<StrokeDefinition>;

    /// Get the text definition for the shape.
    fn text(&self) -> &Rc<TextDefinition>;

    /// Set text definition using Rc.
    fn set_text(&mut self, text: Rc<TextDefinition>);

    /// Set stroke definition using Rc.
    fn set_stroke(&mut self, stroke: Rc<StrokeDefinition>);

    /// Returns the minimum content size required for the shape.
    ///
    /// For shapes that support content, this returns a minimum size
    /// to ensure the shape has adequate space. For content-free shapes,
    /// returns a zero size.
    fn min_content_size(&self) -> Size {
        if self.supports_content() {
            Size::new(10.0, 10.0)
        } else {
            Size::default() // Content-free shapes don't need content space
        }
    }

    /// Get the text positioning strategy for this shape
    fn text_positioning_strategy(&self) -> TextPositioningStrategy {
        TextPositioningStrategy::BelowShape
    }
}

/// Enable cloning of `Box<dyn ShapeDefinition>` by delegating to the clone_box method.
/// This allows `Rc::make_mut` to work with `Rc<Box<dyn ShapeDefinition>>`.
impl Clone for Box<dyn ShapeDefinition> {
    fn clone(&self) -> Self {
        self.clone_box()
    }
}

/// A shape instance that combines a definition with content size and padding
#[derive(Debug, Clone)]
pub struct Shape {
    definition: Rc<Box<dyn ShapeDefinition>>,
    content_size: Size,
    padding: Insets,
}

impl Shape {
    pub fn new(definition: Rc<Box<dyn ShapeDefinition>>) -> Self {
        let content_size = definition.min_content_size();
        Self {
            definition,
            content_size,
            padding: Insets::default(),
        }
    }

    /// Returns true if this shape supports containing content.
    /// This is intended for use within the `draw` module.
    pub(super) fn supports_content(&self) -> bool {
        self.definition.supports_content()
    }

    pub fn content_size(&self) -> Size {
        self.content_size
    }

    /// Get the text positioning strategy for this shape
    pub fn text_positioning_strategy(&self) -> TextPositioningStrategy {
        self.definition.text_positioning_strategy()
    }

    /// Returns the inner size of the shape boundary, excluding stroke.
    /// This is the size needed to contain the content with padding.
    pub fn inner_size(&self) -> Size {
        self.definition
            .calculate_inner_size(self.content_size, self.padding)
    }

    /// Returns the outer size of the shape, including stroke.
    /// This is the full size the shape occupies when rendered.
    pub fn outer_size(&self) -> Size {
        self.definition
            .calculate_outer_size(self.content_size, self.padding)
    }

    /// Expand the content size for this shape to the given size if it's bigger
    /// This is only valid for content-supporting shapes
    pub fn expand_content_size_to(&mut self, content_size: Size) -> Result<(), &'static str> {
        if self.supports_content() {
            self.content_size = self.content_size.max(content_size);
            Ok(())
        } else {
            Err("Cannot expand content size on content-free shapes")
        }
    }

    /// Set the padding for this shape
    pub fn set_padding(&mut self, padding: Insets) {
        self.padding = padding;
    }

    /// Get the current padding for this shape
    pub fn padding(&self) -> Insets {
        self.padding
    }

    /// Find the intersection point where a line from point a to point b intersects with this shape
    pub fn find_intersection(&self, a: Point, b: Point, a_size: Size) -> Point {
        self.definition.find_intersection(a, b, a_size)
    }

    /// Calculate the minimum point offset for positioning content within this shape's container.
    ///
    /// This method computes the offset needed to position embedded content within a shape,
    /// taking into account the difference between the shape's total size and its content size.
    /// The result represents the padding/margin space that should be applied when positioning
    /// nested content within this shape.
    ///
    /// Calculate any additional space the shape needs beyond content + padding.
    /// This accounts for shapes like ovals that need extra room beyond just padding.
    // TODO: Validate we need this. i.e. it is not always zero.
    pub(super) fn calculate_additional_space(&self) -> Size {
        let shape_size = self.inner_size();
        let content_size = self.content_size();
        let total_padding_size = content_size.add_padding(self.padding);

        Size::new(
            shape_size.width() - total_padding_size.width(),
            shape_size.height() - total_padding_size.height(),
        )
        .max(Size::default())
    }

    /// Returns a Point representing the (x, y) offset from the shape's top-left corner
    /// to where the content area begins.
    pub fn shape_to_container_min_point(&self) -> Point {
        let additional_space = self.calculate_additional_space();

        Point::new(
            self.padding.left() + additional_space.width() / 2.0,
            self.padding.top() + additional_space.height() / 2.0,
        )
    }
}

impl Drawable for Shape {
    fn render_to_layers(&self, position: Point) -> LayeredOutput {
        let mut output = LayeredOutput::new();
        let size = self.inner_size();
        let node = self.definition.render_to_svg(size, position);
        output.add_to_layer(RenderLayer::Content, node);
        output
    }

    fn size(&self) -> Size {
        self.outer_size() // TODO merge them.
    }
}

fn find_rectangle_intersection(a: Point, b: Point, a_size: Size) -> Point {
    let half_width = a_size.width() / 2.0;
    let half_height = a_size.height() / 2.0;

    // Rectangle center is at a
    let rect_center = a;

    let dist = b.sub_point(a);

    // Normalize the direction vector
    let length = dist.hypot();
    if length < 0.001 {
        // Avoid division by zero
        return b;
    }

    let dx_norm = dist.x() / length;
    let dy_norm = dist.y() / length;

    // Find intersection with each edge of the rectangle
    // We're calculating how far we need to go along the ray to hit each edge

    // Distance to horizontal edges (top and bottom)
    let t_top = (rect_center.y() - half_height - a.y()) / dy_norm;
    let t_bottom = (rect_center.y() + half_height - a.y()) / dy_norm;

    // Distance to vertical edges (left and right)
    let t_left = (rect_center.x() - half_width - a.x()) / dx_norm;
    let t_right = (rect_center.x() + half_width - a.x()) / dx_norm;

    // Find the smallest positive t value (first intersection with rectangle)
    let mut t = f32::MAX;

    // Check each edge and find the closest valid intersection
    if t_top.is_finite() && t_top > 0.0 {
        let x = dx_norm.mul_add(t_top, a.x()); // a.x + t_top * dx_norm
        if x >= rect_center.x() - half_width && x <= rect_center.x() + half_width {
            t = t_top;
        }
    }

    if t_bottom.is_finite() && t_bottom > 0.0 && t_bottom < t {
        let x = dx_norm.mul_add(t_bottom, a.x()); // a.x + t_bottom * dx_norm
        if x >= rect_center.x() - half_width && x <= rect_center.x() + half_width {
            t = t_bottom;
        }
    }

    if t_left.is_finite() && t_left > 0.0 && t_left < t {
        let y = dy_norm.mul_add(t_left, a.y()); // a.y + t_left * dy_norm
        if y >= rect_center.y() - half_height && y <= rect_center.y() + half_height {
            t = t_left;
        }
    }

    if t_right.is_finite() && t_right > 0.0 && t_right < t {
        let y = dy_norm.mul_add(t_right, a.y()); // a.y + t_right * dy_norm
        if y >= rect_center.y() - half_height && y <= rect_center.y() + half_height {
            t = t_right;
        }
    }

    if t == f32::MAX || !t.is_finite() {
        return b; // Fallback if no intersection found
    }

    // Calculate the intersection point
    Point::new(
        dx_norm.mul_add(t, a.x()), //a.x + dx_norm * t
        dy_norm.mul_add(t, a.y()), // a.y + dy_norm * t
    )
}

#[cfg(test)]
mod tests {
    use float_cmp::assert_approx_eq;

    use super::*;

    fn assert_point_eq(actual: Point, expected: Point) {
        assert_approx_eq!(f32, actual.x(), expected.x());
        assert_approx_eq!(f32, actual.y(), expected.y());
    }

    #[test]
    fn test_intersection_from_right() {
        // Ray from center (100,100) going right to (200,100)
        // Should intersect right edge at (120, 100)
        let a = Point::new(100.0, 100.0);
        let b = Point::new(200.0, 100.0);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, Point::new(120.0, 100.0));
    }

    #[test]
    fn test_intersection_from_left() {
        // Ray from center (100,100) going left to (0,100)
        // Should intersect left edge at (80, 100)
        let a = Point::new(100.0, 100.0);
        let b = Point::new(0.0, 100.0);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, Point::new(80.0, 100.0));
    }

    #[test]
    fn test_intersection_from_bottom() {
        // Ray from center (100,100) going down to (100,200)
        // Should intersect bottom edge at (100, 120)
        let a = Point::new(100.0, 100.0);
        let b = Point::new(100.0, 200.0);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, Point::new(100.0, 120.0));
    }

    #[test]
    fn test_intersection_from_top() {
        // Ray from center (100,100) going up to (100,0)
        // Should intersect top edge at (100, 80)
        let a = Point::new(100.0, 100.0);
        let b = Point::new(100.0, 0.0);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, Point::new(100.0, 80.0));
    }

    #[test]
    fn test_intersection_diagonal() {
        // Ray from center (100,100) going diagonally to (200,200)
        // For a square, 45-degree diagonal hits corner region
        // Should intersect at (120, 120) - the corner of the rectangle
        let a = Point::new(100.0, 100.0);
        let b = Point::new(200.0, 200.0);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, Point::new(120.0, 120.0));
    }

    #[test]
    fn test_intersection_same_point() {
        // Edge case: start and end are the same point
        // Should return b as fallback (avoid division by zero)
        let a = Point::new(100.0, 100.0);
        let b = Point::new(100.0, 100.0);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, b);
    }

    #[test]
    fn test_intersection_zero_size() {
        // Edge case: zero-size shape (degenerate rectangle)
        // All edges collapse to center, no valid intersection possible
        // Should return b as fallback
        let a = Point::new(100.0, 100.0);
        let b = Point::new(200.0, 100.0);
        let size = Size::new(0.0, 0.0);

        let result = find_rectangle_intersection(a, b, size);

        assert_point_eq(result, b);
    }

    #[test]
    fn test_intersection_very_close_points() {
        // Edge case: points extremely close together (distance < 0.001)
        // Algorithm returns b as fallback to avoid numerical instability
        let a = Point::new(100.0, 100.0);
        let b = Point::new(100.0005, 100.0005);
        let size = Size::new(40.0, 40.0);

        let result = find_rectangle_intersection(a, b, size);

        // Distance ≈ 0.000707 < 0.001 threshold, so returns b
        assert_point_eq(result, b);
    }
}

#[cfg(test)]
mod proptest_tests {
    use float_cmp::approx_eq;
    use proptest::prelude::*;

    use super::*;

    // ===================
    // Strategies
    // ===================

    fn point_strategy() -> impl Strategy<Value = Point> {
        (-1000.0f32..1000.0, -1000.0f32..1000.0).prop_map(|(x, y)| Point::new(x, y))
    }

    fn size_strategy() -> impl Strategy<Value = Size> {
        (0.0f32..1000.0, 0.0f32..1000.0).prop_map(|(w, h)| Size::new(w, h))
    }

    // ===================
    // Property Test Functions
    // ===================

    /// The intersection result should always have finite coordinates (no NaN or infinity).
    fn check_intersection_result_is_finite(
        a: Point,
        b: Point,
        size: Size,
    ) -> Result<(), TestCaseError> {
        let result = find_rectangle_intersection(a, b, size);

        let x = result.x();
        let y = result.y();
        prop_assert!(x.is_finite(), "x coordinate is not finite: {x}");
        prop_assert!(y.is_finite(), "y coordinate is not finite: {y}");
        Ok(())
    }

    /// The intersection should either be on the rectangle boundary or equal to b (fallback).
    fn check_intersection_on_boundary_or_fallback(
        a: Point,
        b: Point,
        size: Size,
    ) -> Result<(), TestCaseError> {
        let result = find_rectangle_intersection(a, b, size);

        let half_w = size.width() / 2.0;
        let half_h = size.height() / 2.0;

        // Check if result is on any of the four edges (with tolerance)
        let on_left = approx_eq!(f32, result.x(), a.x() - half_w, epsilon = 0.1);
        let on_right = approx_eq!(f32, result.x(), a.x() + half_w, epsilon = 0.1);
        let on_top = approx_eq!(f32, result.y(), a.y() - half_h, epsilon = 0.1);
        let on_bottom = approx_eq!(f32, result.y(), a.y() + half_h, epsilon = 0.1);

        // Or check if result equals b (fallback case)
        let is_fallback = approx_eq!(f32, result.x(), b.x(), epsilon = 0.1)
            && approx_eq!(f32, result.y(), b.y(), epsilon = 0.1);

        prop_assert!(
            on_left || on_right || on_top || on_bottom || is_fallback,
            "Result {result:?} is neither on boundary of rect at {a:?} with size {size:?} nor fallback to {b:?}"
        );
        Ok(())
    }

    // ===================
    // Proptest Wrappers
    // ===================

    proptest! {
        #[test]
        fn intersection_result_is_finite(a in point_strategy(), b in point_strategy(), size in size_strategy()) {
            check_intersection_result_is_finite(a, b, size)?;
        }

        #[test]
        fn intersection_on_boundary_or_fallback(a in point_strategy(), b in point_strategy(), size in size_strategy()) {
            check_intersection_on_boundary_or_fallback(a, b, size)?;
        }
    }
}