Skip to main content

dotloom_geometry/
shape.rs

1//! Canonical shape model.
2
3use core::f64::consts::{FRAC_PI_2, TAU};
4use std::borrow::Cow;
5
6use serde::{Deserialize, Serialize};
7
8use crate::font_metrics as fm;
9use crate::{
10    Aabb, Affine, Arc, Circle, CubicBez, Curve, FlattenTolerance, GeoResult, GeometryError, LinearKind, ModelTolerance,
11    Orientation, Point, QuadBez, Segment, Vector, curve::SIMILARITY_REL, intersect, orientation,
12};
13
14/// A point marker.
15#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
16pub struct PointShape {
17    /// Location.
18    pub at: Point,
19}
20
21/// Open or closed polyline whose segments may be circular arcs (DXF-style bulge).
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Polyline {
24    /// Vertices.
25    pub points: Vec<Point>,
26    /// Bulge per segment (`tan(sweep/4)`); empty means all straight.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub bulges: Vec<f64>,
29    /// Whether the last vertex connects back to the first.
30    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
31    pub closed: bool,
32}
33
34impl Polyline {
35    /// Straight open polyline.
36    #[must_use]
37    pub fn open(points: Vec<Point>) -> Self {
38        Self { points, bulges: Vec::new(), closed: false }
39    }
40
41    /// Straight closed polyline.
42    #[must_use]
43    pub fn closed(points: Vec<Point>) -> Self {
44        Self { points, bulges: Vec::new(), closed: true }
45    }
46
47    /// Number of segments.
48    #[must_use]
49    pub fn segment_count(&self) -> usize {
50        let n = self.points.len();
51        if n < 2 {
52            0
53        } else if self.closed {
54            n
55        } else {
56            n - 1
57        }
58    }
59
60    /// Bulge of segment `i` (0 when absent).
61    #[must_use]
62    pub fn bulge(&self, i: usize) -> f64 {
63        self.bulges.get(i).copied().unwrap_or(0.0)
64    }
65
66    /// Whether any segment is an arc.
67    #[must_use]
68    pub fn has_arcs(&self) -> bool {
69        self.bulges.iter().any(|b| *b != 0.0)
70    }
71
72    /// Segment `i` as a curve.
73    #[must_use]
74    pub fn segment(&self, i: usize) -> Option<Curve> {
75        let n = self.points.len();
76        if i >= self.segment_count() {
77            return None;
78        }
79        let a = *self.points.get(i)?;
80        let b = *self.points.get((i + 1) % n)?;
81        let bulge = self.bulge(i);
82        if bulge != 0.0
83            && let Ok(arc) = Arc::from_bulge(a, b, bulge)
84        {
85            return Some(Curve::Arc(arc));
86        }
87        Some(Curve::Line(Segment::new(a, b)))
88    }
89}
90
91/// Axis-aligned rectangle in local coordinates (rotation lives in the entity transform).
92#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
93pub struct Rect {
94    /// Minimum corner.
95    pub origin: Point,
96    /// Width (≥ 0).
97    pub width: f64,
98    /// Height (≥ 0).
99    pub height: f64,
100}
101
102impl Rect {
103    /// Rectangle from two opposite corners.
104    #[must_use]
105    pub fn from_corners(a: Point, b: Point) -> Self {
106        Self { origin: Point::new(a.x.min(b.x), a.y.min(b.y)), width: (a.x - b.x).abs(), height: (a.y - b.y).abs() }
107    }
108
109    /// Corners counter-clockwise from the origin.
110    #[must_use]
111    pub fn corners(&self) -> [Point; 4] {
112        let o = self.origin;
113        [
114            o,
115            Point::new(o.x + self.width, o.y),
116            Point::new(o.x + self.width, o.y + self.height),
117            Point::new(o.x, o.y + self.height),
118        ]
119    }
120
121    /// Center.
122    #[must_use]
123    pub fn center(&self) -> Point {
124        Point::new(self.origin.x + self.width * 0.5, self.origin.y + self.height * 0.5)
125    }
126}
127
128/// Polygon with straight edges and optional holes (even-odd fill).
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct Polygon {
131    /// Outer ring (implicitly closed).
132    pub outer: Vec<Point>,
133    /// Hole rings.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub holes: Vec<Vec<Point>>,
136}
137
138/// Path element.
139#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
140pub enum PathEl {
141    /// Start a new subpath.
142    #[serde(rename = "M")]
143    MoveTo(Point),
144    /// Straight line.
145    #[serde(rename = "L")]
146    LineTo(Point),
147    /// Quadratic Bézier (control, end).
148    #[serde(rename = "Q")]
149    QuadTo(Point, Point),
150    /// Cubic Bézier (control 1, control 2, end).
151    #[serde(rename = "C")]
152    CubicTo(Point, Point, Point),
153    /// Close the current subpath.
154    #[serde(rename = "Z")]
155    Close,
156}
157
158/// A Bézier path made of subpaths.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct Path {
161    /// Elements; must start with `MoveTo`.
162    pub elements: Vec<PathEl>,
163}
164
165/// One subpath of a [`Path`] decomposed into curves.
166#[derive(Debug, Clone, PartialEq)]
167pub struct SubPath {
168    /// Curve pieces in order.
169    pub curves: Vec<Curve>,
170    /// Whether the subpath is closed.
171    pub closed: bool,
172}
173
174impl Path {
175    /// Decompose into subpaths of curves.
176    #[must_use]
177    pub fn subpaths(&self) -> Vec<SubPath> {
178        let mut out = Vec::new();
179        let mut cur: Vec<Curve> = Vec::new();
180        let mut start = Point::ORIGIN;
181        let mut pen = Point::ORIGIN;
182        let mut open = false;
183        for el in &self.elements {
184            match *el {
185                PathEl::MoveTo(p) => {
186                    if open && !cur.is_empty() {
187                        out.push(SubPath { curves: core::mem::take(&mut cur), closed: false });
188                    }
189                    cur.clear();
190                    start = p;
191                    pen = p;
192                    open = true;
193                }
194                PathEl::LineTo(p) => {
195                    cur.push(Curve::Line(Segment::new(pen, p)));
196                    pen = p;
197                }
198                PathEl::QuadTo(c, p) => {
199                    cur.push(Curve::Cubic(QuadBez { p0: pen, p1: c, p2: p }.to_cubic()));
200                    pen = p;
201                }
202                PathEl::CubicTo(c1, c2, p) => {
203                    cur.push(Curve::Cubic(CubicBez { p0: pen, p1: c1, p2: c2, p3: p }));
204                    pen = p;
205                }
206                PathEl::Close => {
207                    if pen != start {
208                        cur.push(Curve::Line(Segment::new(pen, start)));
209                    }
210                    if !cur.is_empty() {
211                        out.push(SubPath { curves: core::mem::take(&mut cur), closed: true });
212                    }
213                    pen = start;
214                    open = false;
215                }
216            }
217        }
218        if open && !cur.is_empty() {
219            out.push(SubPath { curves: cur, closed: false });
220        }
221        out
222    }
223
224    fn points_mut(&mut self) -> impl Iterator<Item = &mut Point> {
225        self.elements.iter_mut().flat_map(|el| -> Vec<&mut Point> {
226            match el {
227                PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p],
228                PathEl::QuadTo(a, b) => vec![a, b],
229                PathEl::CubicTo(a, b, c) => vec![a, b, c],
230                PathEl::Close => vec![],
231            }
232        })
233    }
234
235    /// On-curve points (subpath starts and segment ends).
236    #[must_use]
237    pub fn on_curve_points(&self) -> Vec<Point> {
238        self.elements
239            .iter()
240            .filter_map(|el| match *el {
241                PathEl::MoveTo(p) | PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CubicTo(_, _, p) => Some(p),
242                PathEl::Close => None,
243            })
244            .collect()
245    }
246}
247
248/// Horizontal text alignment.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub enum HAlign {
252    /// Anchor at the left edge.
253    #[default]
254    Left,
255    /// Anchor at the center.
256    Center,
257    /// Anchor at the right edge.
258    Right,
259}
260
261/// Vertical text alignment.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub enum VAlign {
265    /// Anchor on the first line's baseline.
266    #[default]
267    Baseline,
268    /// Anchor at the vertical middle of the block.
269    Middle,
270    /// Anchor at the top of the block.
271    Top,
272    /// Anchor at the bottom of the block.
273    Bottom,
274}
275
276/// Text annotation. Height is the cap-to-descender line height in model units.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub struct Text {
279    /// Insertion point.
280    pub position: Point,
281    /// UTF-8 content; `\n` separates lines.
282    pub content: String,
283    /// Text height in model units.
284    pub height: f64,
285    /// Rotation in radians.
286    #[serde(default)]
287    pub rotation: f64,
288    /// Horizontal alignment.
289    #[serde(default)]
290    pub halign: HAlign,
291    /// Vertical alignment.
292    #[serde(default)]
293    pub valign: VAlign,
294}
295
296impl Text {
297    /// Line spacing relative to height.
298    pub const LINE_SPACING: f64 = 1.2;
299    /// Top of the text block above the first baseline (relative to height) for
300    /// `Top`, `Middle` and `Bottom` alignment — the renderer's layout contract.
301    pub const TOP_ABOVE_BASELINE: f64 = 0.8;
302
303    /// Model units per em for text of `height` (height = cap height + descender).
304    fn em(height: f64) -> f64 {
305        height / ((fm::CAP_HEIGHT + fm::DESCENT) / fm::UNITS_PER_EM)
306    }
307
308    /// Font units per em of the renderer's default font (the unit of
309    /// [`Text::line_pens`]).
310    pub const FONT_UNITS_PER_EM: f64 = fm::UNITS_PER_EM;
311
312    /// Model units per em for text of `height` (for renderers that place glyph
313    /// outlines with [`Text::line_pens`]).
314    #[must_use]
315    pub fn em_size(height: f64) -> f64 {
316        Self::em(height)
317    }
318
319    /// Pen positions of one line laid out with the renderer's default font: each
320    /// drawn character (after the renderer's substitutions: tabs and no-break
321    /// spaces become spaces, control characters are dropped) with its pen position
322    /// in font units from the line start — advance widths plus pair kerning — and
323    /// the line's total advance in font units. The renderer places glyphs with
324    /// exactly these positions, so drawn text and [`Text::layout_box`] agree.
325    #[must_use]
326    pub fn line_pens(line: &str) -> (Vec<(char, f64)>, f64) {
327        let mut pens = Vec::with_capacity(line.len());
328        let mut pen = 0.0;
329        let mut prev: Option<usize> = None;
330        for c in line.trim_end_matches('\r').chars() {
331            let Some(c) = substitute(c) else { continue };
332            let idx = fm::ADVANCES.binary_search_by_key(&u32::from(c), |e| e.0).ok();
333            if let (Some(a), Some(b)) = (prev, idx) {
334                pen += kerning(a, b);
335            }
336            pens.push((c, pen));
337            pen += f64::from(idx.and_then(|i| fm::ADVANCES.get(i)).map_or(fm::NOTDEF_ADVANCE, |e| e.1));
338            prev = idx;
339        }
340        (pens, pen)
341    }
342
343    /// Advance width of one line in model units, measured with the renderer's
344    /// default font (advance widths and pair kerning — exactly how it is drawn).
345    /// Tabs count as spaces; control characters are skipped.
346    #[must_use]
347    pub fn line_width(line: &str, height: f64) -> f64 {
348        Self::line_pens(line).1 / fm::UNITS_PER_EM * Self::em(height)
349    }
350
351    /// Layout box (before rotation) relative to `position`: each line's advance
352    /// width with its alignment, from the ascender of the first line to the
353    /// descender of the last. Matches the renderer's layout of the default font.
354    #[must_use]
355    pub fn layout_box(&self) -> Aabb {
356        let h = self.height;
357        let em = Self::em(h);
358        let lines: Vec<&str> = self.content.split('\n').collect();
359        let n = lines.len().max(1) as f64;
360        let block_h = h * (1.0 + (n - 1.0) * Self::LINE_SPACING);
361        let first_baseline = match self.valign {
362            VAlign::Baseline => 0.0,
363            VAlign::Top => -Self::TOP_ABOVE_BASELINE * h,
364            VAlign::Middle => block_h * 0.5 - Self::TOP_ABOVE_BASELINE * h,
365            VAlign::Bottom => block_h - Self::TOP_ABOVE_BASELINE * h,
366        };
367        let (mut x0, mut x1) = (0.0_f64, 0.0_f64);
368        for line in &lines {
369            let w = Self::line_width(line, h);
370            let start = match self.halign {
371                HAlign::Left => 0.0,
372                HAlign::Center => -w * 0.5,
373                HAlign::Right => -w,
374            };
375            x0 = x0.min(start);
376            x1 = x1.max(start + w);
377        }
378        let top = first_baseline + fm::ASCENT / fm::UNITS_PER_EM * em;
379        let bottom = first_baseline - (n - 1.0) * Self::LINE_SPACING * h - fm::DESCENT / fm::UNITS_PER_EM * em;
380        Aabb::from_corners(Point::new(x0, top), Point::new(x1, bottom))
381    }
382
383    /// The four corners of the layout box in model space.
384    #[must_use]
385    pub fn layout_corners(&self) -> [Point; 4] {
386        let t = Affine::rotate(self.rotation).then(Affine::translate(self.position.to_vector()));
387        self.layout_box().corners().map(|c| t.apply(c))
388    }
389}
390
391/// The renderer's character substitutions: tabs become spaces and other controls
392/// are dropped; characters the font lacks fall back (no-break spaces to a space,
393/// the diameter sign to the empty-set sign).
394fn substitute(c: char) -> Option<char> {
395    match c {
396        '\t' => return Some(' '),
397        c if c.is_control() => return None,
398        _ => {}
399    }
400    if fm::ADVANCES.binary_search_by_key(&u32::from(c), |e| e.0).is_ok() {
401        return Some(c);
402    }
403    Some(match c {
404        '\u{00a0}' | '\u{2007}' | '\u{202f}' => ' ',
405        '\u{2300}' => '\u{2205}',
406        c => c,
407    })
408}
409
410/// Pair kerning between two characters of the default font (indices into the
411/// advance table) in font units. Per `kern` lookup an explicit pair wins over the
412/// class matrix; the lookups add up (OpenType GPOS pair positioning).
413fn kerning(a: usize, b: usize) -> f64 {
414    let key = (u16::try_from(a).unwrap_or(u16::MAX), u16::try_from(b).unwrap_or(u16::MAX));
415    let mut units = 0i32;
416    for k in &fm::KERN {
417        if let Ok(i) = k.pairs.binary_search_by_key(&key, |p| (p.0, p.1)) {
418            units += k.pairs.get(i).map_or(0, |p| i32::from(p.2));
419            continue;
420        }
421        let (Some(&left), Some(&right)) = (k.left.get(a), k.right.get(b)) else { continue };
422        if left == u8::MAX {
423            continue;
424        }
425        units += k.matrix.get(usize::from(left) * k.columns + usize::from(right)).map_or(0, |v| i32::from(*v));
426    }
427    f64::from(units)
428}
429
430/// How to handle transforms that a shape cannot represent exactly.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub enum TransformPolicy {
434    /// Return [`GeometryError::UnsupportedTransform`].
435    #[default]
436    Strict,
437    /// Convert to a [`Path`]/[`Polygon`] that represents the transformed shape
438    /// (circles/arcs become cubic approximations with < 0.03 % radial error).
439    Convert,
440}
441
442/// Semantic anchor kinds, used by snapping and constraints.
443#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub enum AnchorKind {
446    /// Start/end of an open curve.
447    Endpoint,
448    /// Midpoint of a segment or arc.
449    Midpoint,
450    /// Center of a circle/arc/rectangle.
451    Center,
452    /// Polyline/polygon vertex.
453    Vertex,
454    /// Circle quadrant point.
455    Quadrant,
456    /// Rectangle corner.
457    Corner,
458    /// Text insertion point.
459    Insert,
460    /// Area centroid.
461    Centroid,
462    /// Point shape location.
463    Node,
464}
465
466/// A named semantic anchor of a shape.
467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
468pub struct Anchor {
469    /// Stable name within the shape (`start`, `end`, `mid`, `center`, `v3`, ...).
470    /// Built-in names are static (no allocation per anchor: engines cache the
471    /// anchors of every entity).
472    pub name: Cow<'static, str>,
473    /// Kind.
474    pub kind: AnchorKind,
475    /// Position.
476    pub point: Point,
477}
478
479impl Anchor {
480    fn new(name: impl Into<Cow<'static, str>>, kind: AnchorKind, point: Point) -> Self {
481        Self { name: name.into(), kind, point }
482    }
483}
484
485/// `prefix{i}` names (`v0`, `m3`, `c1`, …); the first 32 of each are static.
486fn indexed(prefix: char, i: usize) -> Cow<'static, str> {
487    const N: usize = 32;
488    macro_rules! table {
489        ($p:literal) => {
490            [
491                concat!($p, "0"),
492                concat!($p, "1"),
493                concat!($p, "2"),
494                concat!($p, "3"),
495                concat!($p, "4"),
496                concat!($p, "5"),
497                concat!($p, "6"),
498                concat!($p, "7"),
499                concat!($p, "8"),
500                concat!($p, "9"),
501                concat!($p, "10"),
502                concat!($p, "11"),
503                concat!($p, "12"),
504                concat!($p, "13"),
505                concat!($p, "14"),
506                concat!($p, "15"),
507                concat!($p, "16"),
508                concat!($p, "17"),
509                concat!($p, "18"),
510                concat!($p, "19"),
511                concat!($p, "20"),
512                concat!($p, "21"),
513                concat!($p, "22"),
514                concat!($p, "23"),
515                concat!($p, "24"),
516                concat!($p, "25"),
517                concat!($p, "26"),
518                concat!($p, "27"),
519                concat!($p, "28"),
520                concat!($p, "29"),
521                concat!($p, "30"),
522                concat!($p, "31"),
523            ]
524        };
525    }
526    static V: [&str; N] = table!("v");
527    static M: [&str; N] = table!("m");
528    static C: [&str; N] = table!("c");
529    static E: [&str; N] = table!("e");
530    static Q: [&str; N] = table!("q");
531    let table: Option<&[&'static str; N]> = match prefix {
532        'v' => Some(&V),
533        'm' => Some(&M),
534        'c' => Some(&C),
535        'e' => Some(&E),
536        'q' => Some(&Q),
537        _ => None,
538    };
539    match table.and_then(|t| t.get(i)) {
540        Some(name) => Cow::Borrowed(name),
541        None => Cow::Owned(format!("{prefix}{i}")),
542    }
543}
544
545/// Shape kinds.
546#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
547#[serde(rename_all = "camelCase")]
548pub enum ShapeKind {
549    /// Point marker.
550    Point,
551    /// Line segment.
552    Line,
553    /// Polyline.
554    Polyline,
555    /// Rectangle.
556    Rect,
557    /// Circle.
558    Circle,
559    /// Arc.
560    Arc,
561    /// Bézier path.
562    Path,
563    /// Polygon.
564    Polygon,
565    /// Text.
566    Text,
567}
568
569impl ShapeKind {
570    /// Stable lowercase name.
571    #[must_use]
572    pub const fn name(self) -> &'static str {
573        match self {
574            Self::Point => "point",
575            Self::Line => "line",
576            Self::Polyline => "polyline",
577            Self::Rect => "rect",
578            Self::Circle => "circle",
579            Self::Arc => "arc",
580            Self::Path => "path",
581            Self::Polygon => "polygon",
582            Self::Text => "text",
583        }
584    }
585}
586
587/// A canonical 2D shape.
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589#[serde(tag = "type", rename_all = "camelCase")]
590pub enum Shape {
591    /// Point marker.
592    Point(PointShape),
593    /// Line segment.
594    Line(Segment),
595    /// Polyline (optionally closed, optional arc segments).
596    Polyline(Polyline),
597    /// Axis-aligned rectangle.
598    Rect(Rect),
599    /// Circle.
600    Circle(Circle),
601    /// Circular arc.
602    Arc(Arc),
603    /// Bézier path.
604    Path(Path),
605    /// Polygon with holes.
606    Polygon(Polygon),
607    /// Text annotation.
608    Text(Text),
609}
610
611/// Flattened ring/curve.
612#[derive(Debug, Clone, PartialEq)]
613pub struct FlatPath {
614    /// Points in order.
615    pub points: Vec<Point>,
616    /// Closed ring.
617    pub closed: bool,
618}
619
620/// Limits enforced by [`Shape::validate`] to bound memory/CPU on untrusted input.
621pub const MAX_SHAPE_POINTS: usize = 1_000_000;
622/// Maximum text length in characters.
623pub const MAX_TEXT_CHARS: usize = 100_000;
624
625impl Shape {
626    /// Kind of the shape.
627    #[must_use]
628    pub const fn kind(&self) -> ShapeKind {
629        match self {
630            Self::Point(_) => ShapeKind::Point,
631            Self::Line(_) => ShapeKind::Line,
632            Self::Polyline(_) => ShapeKind::Polyline,
633            Self::Rect(_) => ShapeKind::Rect,
634            Self::Circle(_) => ShapeKind::Circle,
635            Self::Arc(_) => ShapeKind::Arc,
636            Self::Path(_) => ShapeKind::Path,
637            Self::Polygon(_) => ShapeKind::Polygon,
638            Self::Text(_) => ShapeKind::Text,
639        }
640    }
641
642    /// Validate numeric sanity and structural rules.
643    pub fn validate(&self) -> GeoResult<()> {
644        let all_finite = |pts: &[Point], what: &'static str| -> GeoResult<()> {
645            if pts.len() > MAX_SHAPE_POINTS {
646                return Err(GeometryError::InvalidArgument("too many points in shape"));
647            }
648            if pts.iter().all(|p| p.is_finite()) { Ok(()) } else { Err(GeometryError::NonFinite(what)) }
649        };
650        match self {
651            Self::Point(p) => all_finite(&[p.at], "point"),
652            Self::Line(s) => all_finite(&[s.a, s.b], "line"),
653            Self::Polyline(p) => {
654                all_finite(&p.points, "polyline")?;
655                if p.points.len() < 2 {
656                    return Err(GeometryError::Degenerate("polyline needs at least 2 points"));
657                }
658                if !p.bulges.is_empty() && p.bulges.len() != p.segment_count() {
659                    return Err(GeometryError::InvalidArgument("bulge count must equal segment count"));
660                }
661                if p.bulges.iter().any(|b| !b.is_finite()) {
662                    return Err(GeometryError::NonFinite("polyline bulge"));
663                }
664                Ok(())
665            }
666            Self::Rect(r) => {
667                all_finite(&[r.origin], "rect")?;
668                if !(r.width.is_finite() && r.height.is_finite()) {
669                    return Err(GeometryError::NonFinite("rect size"));
670                }
671                if r.width < 0.0 || r.height < 0.0 {
672                    return Err(GeometryError::InvalidArgument("rect size must be non-negative"));
673                }
674                Ok(())
675            }
676            Self::Circle(c) => Circle::new(c.center, c.radius).map(|_| ()),
677            Self::Arc(a) => {
678                Arc::new(a.center, a.radius, a.start, a.sweep)?;
679                if a.sweep.abs() > TAU {
680                    return Err(GeometryError::InvalidArgument("arc sweep exceeds 2π"));
681                }
682                Ok(())
683            }
684            Self::Path(p) => {
685                if p.elements.len() > MAX_SHAPE_POINTS {
686                    return Err(GeometryError::InvalidArgument("too many path elements"));
687                }
688                if !matches!(p.elements.first(), Some(PathEl::MoveTo(_))) {
689                    return Err(GeometryError::InvalidArgument("path must start with MoveTo"));
690                }
691                let mut q = p.clone();
692                if q.points_mut().all(|p| p.is_finite()) { Ok(()) } else { Err(GeometryError::NonFinite("path")) }
693            }
694            Self::Polygon(p) => {
695                all_finite(&p.outer, "polygon")?;
696                if p.outer.len() < 3 {
697                    return Err(GeometryError::Degenerate("polygon needs at least 3 points"));
698                }
699                for h in &p.holes {
700                    all_finite(h, "polygon hole")?;
701                    if h.len() < 3 {
702                        return Err(GeometryError::Degenerate("polygon hole needs at least 3 points"));
703                    }
704                }
705                Ok(())
706            }
707            Self::Text(t) => {
708                all_finite(&[t.position], "text")?;
709                if !(t.height.is_finite() && t.rotation.is_finite()) {
710                    return Err(GeometryError::NonFinite("text metrics"));
711                }
712                if t.height <= 0.0 {
713                    return Err(GeometryError::InvalidArgument("text height must be > 0"));
714                }
715                if t.content.chars().count() > MAX_TEXT_CHARS {
716                    return Err(GeometryError::InvalidArgument("text too long"));
717                }
718                Ok(())
719            }
720        }
721    }
722
723    /// Decompose into curve pieces. Points and text have none.
724    #[must_use]
725    pub fn curves(&self) -> Vec<Curve> {
726        match self {
727            Self::Point(_) | Self::Text(_) => Vec::new(),
728            Self::Line(s) => vec![Curve::Line(*s)],
729            Self::Polyline(p) => (0..p.segment_count()).filter_map(|i| p.segment(i)).collect(),
730            Self::Rect(r) => ring_lines(&r.corners()),
731            Self::Circle(c) => vec![Curve::Circle(*c)],
732            Self::Arc(a) => vec![Curve::Arc(*a)],
733            Self::Path(p) => p.subpaths().into_iter().flat_map(|s| s.curves).collect(),
734            Self::Polygon(p) => {
735                let mut v = ring_lines(&p.outer);
736                for h in &p.holes {
737                    v.extend(ring_lines(h));
738                }
739                v
740            }
741        }
742    }
743
744    /// Bounding box (text: its layout box with the default font).
745    #[must_use]
746    pub fn bbox(&self) -> Aabb {
747        match self {
748            Self::Point(p) => Aabb::from_corners(p.at, p.at),
749            Self::Text(t) => Aabb::from_points(t.layout_corners()),
750            Self::Rect(r) => Aabb::from_points(r.corners()),
751            Self::Polygon(p) => Aabb::from_points(p.outer.iter().copied()),
752            _ => self.curves().iter().fold(Aabb::EMPTY, |b, c| b.union(c.bbox())),
753        }
754    }
755
756    /// Whether the shape bounds an area (fill and inside tests apply).
757    #[must_use]
758    pub fn is_region(&self) -> bool {
759        match self {
760            Self::Rect(_) | Self::Circle(_) | Self::Polygon(_) => true,
761            Self::Polyline(p) => p.closed && p.points.len() >= 3,
762            Self::Path(p) => {
763                let subs = p.subpaths();
764                !subs.is_empty() && subs.iter().all(|s| s.closed)
765            }
766            _ => false,
767        }
768    }
769
770    /// Distance from `p` to the outline (text: to its layout box).
771    #[must_use]
772    pub fn distance_to(&self, p: Point) -> f64 {
773        match self {
774            Self::Point(s) => s.at.distance(p),
775            Self::Text(t) => {
776                if point_in_ring(p, &t.layout_corners()) {
777                    0.0
778                } else {
779                    ring_lines(&t.layout_corners()).iter().map(|c| c.distance_to_point(p)).fold(f64::INFINITY, f64::min)
780                }
781            }
782            _ => self.curves().iter().map(|c| c.distance_to_point(p)).fold(f64::INFINITY, f64::min),
783        }
784    }
785
786    /// Even-odd inside test for regions (always false for non-regions).
787    /// Straight edges use exact orientation predicates; curved edges are flattened
788    /// with `tol`.
789    #[must_use]
790    pub fn contains_point(&self, p: Point, tol: FlattenTolerance) -> bool {
791        if !self.is_region() || !self.bbox().contains_point(p) {
792            return false;
793        }
794        match self {
795            Self::Rect(r) => point_in_ring(p, &r.corners()),
796            Self::Circle(c) => p.distance(c.center) <= c.radius,
797            Self::Polygon(poly) => {
798                let mut inside = point_in_ring(p, &poly.outer);
799                for h in &poly.holes {
800                    if point_in_ring(p, h) {
801                        inside = !inside;
802                    }
803                }
804                inside
805            }
806            _ => {
807                let mut inside = false;
808                for ring in self.flatten(tol) {
809                    if ring.closed && point_in_ring(p, &ring.points) {
810                        inside = !inside;
811                    }
812                }
813                inside
814            }
815        }
816    }
817
818    /// Hit test: within `radius` of the outline, or inside when `fill` is set.
819    #[must_use]
820    pub fn hit(&self, p: Point, radius: f64, fill: bool) -> bool {
821        if self.bbox().distance_to_point(p) > radius {
822            return false;
823        }
824        if fill && self.contains_point(p, FlattenTolerance(radius.max(1e-9) * 0.25)) {
825            return true;
826        }
827        self.distance_to(p) <= radius
828    }
829
830    /// Window selection: shape entirely inside `r`.
831    #[must_use]
832    pub fn inside_rect(&self, r: Aabb) -> bool {
833        r.contains(self.bbox())
834    }
835
836    /// Crossing selection: any part of the shape touches `r` (regions: also when
837    /// `r` lies inside the region).
838    #[must_use]
839    pub fn intersects_rect(&self, r: Aabb, tol: ModelTolerance) -> bool {
840        let bb = self.bbox();
841        if !bb.intersects(r) {
842            return false;
843        }
844        if r.contains(bb) {
845            return true;
846        }
847        match self {
848            Self::Point(p) => r.contains_point(p.at),
849            Self::Text(t) => {
850                let corners = t.layout_corners();
851                corners.iter().any(|c| r.contains_point(*c))
852                    || rect_edges(r).iter().any(|e| {
853                        ring_lines(&corners).iter().any(|c| !intersect::intersect(e, c, tol).points.is_empty())
854                    })
855                    || point_in_ring(r.center(), &corners)
856            }
857            _ => {
858                let curves = self.curves();
859                if curves.iter().any(|c| r.contains_point(c.start()) || r.contains_point(c.end())) {
860                    return true;
861                }
862                let edges = rect_edges(r);
863                if curves.iter().any(|c| edges.iter().any(|e| !intersect::intersect(e, c, tol).points.is_empty())) {
864                    return true;
865                }
866                // Curve fully inside rect but endpoints outside is impossible; the
867                // remaining case is the rect fully inside a region.
868                self.is_region() && self.contains_point(r.center(), FlattenTolerance::default())
869            }
870        }
871    }
872
873    /// Flatten into polylines/rings with maximum deviation `tol`.
874    #[must_use]
875    pub fn flatten(&self, tol: FlattenTolerance) -> Vec<FlatPath> {
876        let tol = tol.value();
877        let curves_to_flat = |curves: &[Curve], closed: bool| -> FlatPath {
878            let mut points = Vec::new();
879            if let Some(first) = curves.first() {
880                points.push(first.start());
881            }
882            for c in curves {
883                c.flatten_into(tol, &mut points);
884            }
885            if closed && points.len() > 1 && points.first() == points.last() {
886                points.pop();
887            }
888            FlatPath { points, closed }
889        };
890        match self {
891            Self::Point(p) => vec![FlatPath { points: vec![p.at], closed: false }],
892            Self::Text(t) => vec![FlatPath { points: t.layout_corners().to_vec(), closed: true }],
893            Self::Line(s) => vec![FlatPath { points: vec![s.a, s.b], closed: false }],
894            Self::Rect(r) => vec![FlatPath { points: r.corners().to_vec(), closed: true }],
895            Self::Polygon(p) => core::iter::once(&p.outer)
896                .chain(p.holes.iter())
897                .map(|ring| FlatPath { points: ring.clone(), closed: true })
898                .collect(),
899            Self::Circle(c) => {
900                let mut f = curves_to_flat(&[Curve::Circle(*c)], true);
901                if f.points.len() > 1
902                    && f.points.first().zip(f.points.last()).is_some_and(|(a, b)| a.distance(*b) < tol)
903                {
904                    f.points.pop();
905                }
906                vec![f]
907            }
908            Self::Arc(a) => vec![curves_to_flat(&[Curve::Arc(*a)], false)],
909            Self::Polyline(p) => vec![curves_to_flat(&self.curves(), p.closed)],
910            Self::Path(p) => p.subpaths().iter().map(|s| curves_to_flat(&s.curves, s.closed)).collect(),
911        }
912    }
913
914    /// Semantic anchors.
915    #[must_use]
916    pub fn anchors(&self) -> Vec<Anchor> {
917        use AnchorKind as K;
918        match self {
919            Self::Point(p) => vec![Anchor::new("point", K::Node, p.at)],
920            Self::Line(s) => vec![
921                Anchor::new("start", K::Endpoint, s.a),
922                Anchor::new("end", K::Endpoint, s.b),
923                Anchor::new("mid", K::Midpoint, s.midpoint()),
924            ],
925            Self::Polyline(p) => {
926                let mut v: Vec<Anchor> =
927                    p.points.iter().enumerate().map(|(i, q)| Anchor::new(indexed('v', i), K::Vertex, *q)).collect();
928                for i in 0..p.segment_count() {
929                    if let Some(c) = p.segment(i) {
930                        v.push(Anchor::new(indexed('m', i), K::Midpoint, c.point_at(0.5)));
931                    }
932                }
933                if !p.closed {
934                    if let Some(f) = p.points.first() {
935                        v.push(Anchor::new("start", K::Endpoint, *f));
936                    }
937                    if let Some(l) = p.points.last() {
938                        v.push(Anchor::new("end", K::Endpoint, *l));
939                    }
940                }
941                v
942            }
943            Self::Rect(r) => {
944                let c = r.corners();
945                let mut v: Vec<Anchor> =
946                    c.iter().enumerate().map(|(i, q)| Anchor::new(indexed('c', i), K::Corner, *q)).collect();
947                for i in 0..4 {
948                    v.push(Anchor::new(indexed('e', i), K::Midpoint, c[i].midpoint(c[(i + 1) % 4])));
949                }
950                v.push(Anchor::new("center", K::Center, r.center()));
951                v
952            }
953            Self::Circle(c) => {
954                let mut v = vec![Anchor::new("center", K::Center, c.center)];
955                for i in 0..4u8 {
956                    v.push(Anchor::new(
957                        indexed('q', usize::from(i)),
958                        K::Quadrant,
959                        c.point_at_angle(f64::from(i) * FRAC_PI_2),
960                    ));
961                }
962                v
963            }
964            Self::Arc(a) => vec![
965                Anchor::new("start", K::Endpoint, a.start_point()),
966                Anchor::new("end", K::Endpoint, a.end_point()),
967                Anchor::new("mid", K::Midpoint, a.mid_point()),
968                Anchor::new("center", K::Center, a.center),
969            ],
970            Self::Path(p) => {
971                let pts = p.on_curve_points();
972                let mut v: Vec<Anchor> =
973                    pts.iter().enumerate().map(|(i, q)| Anchor::new(indexed('v', i), K::Vertex, *q)).collect();
974                if let Some(f) = pts.first() {
975                    v.push(Anchor::new("start", K::Endpoint, *f));
976                }
977                if let Some(l) = pts.last() {
978                    v.push(Anchor::new("end", K::Endpoint, *l));
979                }
980                v
981            }
982            Self::Polygon(p) => {
983                let mut v: Vec<Anchor> =
984                    p.outer.iter().enumerate().map(|(i, q)| Anchor::new(indexed('v', i), K::Vertex, *q)).collect();
985                if let Some(c) = ring_centroid(&p.outer) {
986                    v.push(Anchor::new("centroid", K::Centroid, c));
987                }
988                v
989            }
990            Self::Text(t) => vec![Anchor::new("insert", K::Insert, t.position)],
991        }
992    }
993
994    /// Anchor by name.
995    #[must_use]
996    pub fn anchor(&self, name: &str) -> Option<Point> {
997        self.anchors().into_iter().find(|a| a.name == name).map(|a| a.point)
998    }
999
1000    /// Total outline length (text and points: 0).
1001    #[must_use]
1002    pub fn length(&self) -> f64 {
1003        self.curves().iter().map(Curve::length).sum()
1004    }
1005
1006    /// Unsigned area of regions (0 for non-regions). Bézier areas use flattening.
1007    #[must_use]
1008    pub fn area(&self) -> f64 {
1009        match self {
1010            Self::Rect(r) => r.width * r.height,
1011            Self::Circle(c) => core::f64::consts::PI * c.radius * c.radius,
1012            Self::Polygon(p) => {
1013                let mut a = ring_signed_area(&p.outer).abs();
1014                for h in &p.holes {
1015                    a -= ring_signed_area(h).abs();
1016                }
1017                a.max(0.0)
1018            }
1019            Self::Polyline(p) if p.closed => polyline_signed_area(p).abs(),
1020            Self::Path(_) if self.is_region() => self
1021                .flatten(FlattenTolerance(self.bbox().size().length() * 1e-6))
1022                .iter()
1023                .map(|r| ring_signed_area(&r.points))
1024                .sum::<f64>()
1025                .abs(),
1026            _ => 0.0,
1027        }
1028    }
1029
1030    /// Transform the shape. See [`TransformPolicy`].
1031    pub fn transform(&self, t: Affine, policy: TransformPolicy) -> GeoResult<Self> {
1032        if !t.is_finite() {
1033            return Err(GeometryError::NonFinite("transform"));
1034        }
1035        let kind = t.linear_kind(SIMILARITY_REL);
1036        if kind == LinearKind::Singular {
1037            return Err(GeometryError::SingularTransform { determinant: t.determinant() });
1038        }
1039        let unsupported =
1040            |shape: &'static str, reason: &'static str| GeometryError::UnsupportedTransform { shape, reason };
1041        Ok(match self {
1042            Self::Point(p) => Self::Point(PointShape { at: t.apply(p.at) }),
1043            Self::Line(s) => Self::Line(s.transform(t)),
1044            Self::Polyline(p) => {
1045                if p.has_arcs() && !matches!(kind, LinearKind::Similarity { .. }) {
1046                    if policy == TransformPolicy::Strict {
1047                        return Err(unsupported("polyline", "arc segments need a similarity transform"));
1048                    }
1049                    return Self::Path(curves_to_path(&self.curves(), p.closed)).transform(t, policy);
1050                }
1051                let reflected = matches!(kind, LinearKind::Similarity { reflected: true, .. });
1052                Self::Polyline(Polyline {
1053                    points: p.points.iter().map(|q| t.apply(*q)).collect(),
1054                    bulges: if reflected { p.bulges.iter().map(|b| -b).collect() } else { p.bulges.clone() },
1055                    closed: p.closed,
1056                })
1057            }
1058            Self::Rect(r) => {
1059                if t.is_axis_aligned(1e-12) {
1060                    let c = r.corners();
1061                    Self::Rect(Rect::from_corners(t.apply(c[0]), t.apply(c[2])))
1062                } else if policy == TransformPolicy::Convert {
1063                    Self::Polygon(Polygon {
1064                        outer: r.corners().iter().map(|q| t.apply(*q)).collect(),
1065                        holes: Vec::new(),
1066                    })
1067                } else {
1068                    return Err(unsupported("rect", "rotation/shear must be applied through the entity transform"));
1069                }
1070            }
1071            Self::Circle(c) => match c.transform(t) {
1072                Ok(c) => Self::Circle(c),
1073                Err(e) if policy == TransformPolicy::Strict => return Err(e),
1074                Err(_) => {
1075                    let arc = Arc::new(c.center, c.radius, 0.0, TAU)?;
1076                    Self::Path(curves_to_path(&arc_to_cubics(arc), true)).transform(t, policy)?
1077                }
1078            },
1079            Self::Arc(a) => match a.transform(t) {
1080                Ok(a) => Self::Arc(a),
1081                Err(e) if policy == TransformPolicy::Strict => return Err(e),
1082                Err(_) => Self::Path(curves_to_path(&arc_to_cubics(*a), false)).transform(t, policy)?,
1083            },
1084            Self::Path(p) => {
1085                let mut q = p.clone();
1086                for pt in q.points_mut() {
1087                    *pt = t.apply(*pt);
1088                }
1089                Self::Path(q)
1090            }
1091            Self::Polygon(p) => Self::Polygon(Polygon {
1092                outer: p.outer.iter().map(|q| t.apply(*q)).collect(),
1093                holes: p.holes.iter().map(|h| h.iter().map(|q| t.apply(*q)).collect()).collect(),
1094            }),
1095            Self::Text(tx) => match kind {
1096                LinearKind::Similarity { scale, .. } => {
1097                    let dir = t.apply_vector(Vector::from_angle(tx.rotation));
1098                    Self::Text(Text {
1099                        position: t.apply(tx.position),
1100                        height: tx.height * scale,
1101                        rotation: dir.angle(),
1102                        ..tx.clone()
1103                    })
1104                }
1105                _ => return Err(unsupported("text", "text only supports similarity transforms")),
1106            },
1107        })
1108    }
1109}
1110
1111fn ring_lines(pts: &[Point]) -> Vec<Curve> {
1112    let n = pts.len();
1113    (0..n)
1114        .filter_map(|i| {
1115            let a = *pts.get(i)?;
1116            let b = *pts.get((i + 1) % n)?;
1117            Some(Curve::Line(Segment::new(a, b)))
1118        })
1119        .collect()
1120}
1121
1122fn rect_edges(r: Aabb) -> Vec<Curve> {
1123    ring_lines(&r.corners())
1124}
1125
1126/// Even-odd point-in-ring test using exact orientation predicates.
1127#[must_use]
1128pub fn point_in_ring(p: Point, ring: &[Point]) -> bool {
1129    let n = ring.len();
1130    if n < 3 {
1131        return false;
1132    }
1133    let mut inside = false;
1134    let mut j = n - 1;
1135    for i in 0..n {
1136        let (Some(&a), Some(&b)) = (ring.get(i), ring.get(j)) else {
1137            break;
1138        };
1139        if (a.y > p.y) != (b.y > p.y) {
1140            // Edge crosses the horizontal ray; decide side exactly.
1141            let o = orientation(b, a, p);
1142            let upward = a.y > b.y;
1143            let left = if upward { o == Orientation::CounterClockwise } else { o == Orientation::Clockwise };
1144            if left {
1145                inside = !inside;
1146            }
1147        }
1148        j = i;
1149    }
1150    inside
1151}
1152
1153/// Signed area (positive = counter-clockwise).
1154#[must_use]
1155pub fn ring_signed_area(ring: &[Point]) -> f64 {
1156    let n = ring.len();
1157    if n < 3 {
1158        return 0.0;
1159    }
1160    let o = ring.first().copied().unwrap_or(Point::ORIGIN);
1161    let mut s = 0.0;
1162    for i in 0..n {
1163        if let (Some(a), Some(b)) = (ring.get(i), ring.get((i + 1) % n)) {
1164            s += (*a - o).cross(*b - o);
1165        }
1166    }
1167    s * 0.5
1168}
1169
1170fn ring_centroid(ring: &[Point]) -> Option<Point> {
1171    let n = ring.len();
1172    let o = *ring.first()?;
1173    let mut a2 = 0.0;
1174    let (mut cx, mut cy) = (0.0, 0.0);
1175    for i in 0..n {
1176        let p = *ring.get(i)? - o;
1177        let q = *ring.get((i + 1) % n)? - o;
1178        let c = p.cross(q);
1179        a2 += c;
1180        cx += (p.x + q.x) * c;
1181        cy += (p.y + q.y) * c;
1182    }
1183    if a2.abs() < f64::MIN_POSITIVE {
1184        return None;
1185    }
1186    Some(Point::new(o.x + cx / (3.0 * a2), o.y + cy / (3.0 * a2)))
1187}
1188
1189fn polyline_signed_area(p: &Polyline) -> f64 {
1190    let mut a = ring_signed_area(&p.points);
1191    for i in 0..p.segment_count() {
1192        if let Some(Curve::Arc(arc)) = p.segment(i) {
1193            // Signed circular segment area between chord and arc.
1194            let th = arc.sweep;
1195            a += 0.5 * arc.radius * arc.radius * (th - crate::math::sin(th));
1196        }
1197    }
1198    a
1199}
1200
1201/// Approximate an arc by cubic Béziers (≤ 90° each).
1202#[must_use]
1203pub fn arc_to_cubics(a: Arc) -> Vec<Curve> {
1204    let n = (a.sweep.abs() / FRAC_PI_2).ceil().max(1.0) as u32;
1205    let step = a.sweep / f64::from(n);
1206    let k = 4.0 / 3.0 * crate::math::tan(step / 4.0);
1207    (0..n)
1208        .map(|i| {
1209            let a0 = a.start + step * f64::from(i);
1210            let a1 = a0 + step;
1211            let p0 = a.center + Vector::from_angle(a0) * a.radius;
1212            let p3 = a.center + Vector::from_angle(a1) * a.radius;
1213            let p1 = p0 + Vector::from_angle(a0).perp() * (a.radius * k);
1214            let p2 = p3 - Vector::from_angle(a1).perp() * (a.radius * k);
1215            Curve::Cubic(CubicBez { p0, p1, p2, p3 })
1216        })
1217        .collect()
1218}
1219
1220fn curves_to_path(curves: &[Curve], closed: bool) -> Path {
1221    let mut elements = Vec::new();
1222    if let Some(first) = curves.first() {
1223        elements.push(PathEl::MoveTo(first.start()));
1224    }
1225    for c in curves {
1226        match *c {
1227            Curve::Line(s) => elements.push(PathEl::LineTo(s.b)),
1228            Curve::Cubic(b) => elements.push(PathEl::CubicTo(b.p1, b.p2, b.p3)),
1229            Curve::Arc(a) => {
1230                for cb in arc_to_cubics(a) {
1231                    if let Curve::Cubic(b) = cb {
1232                        elements.push(PathEl::CubicTo(b.p1, b.p2, b.p3));
1233                    }
1234                }
1235            }
1236            Curve::Circle(ci) => {
1237                if let Ok(a) = Arc::new(ci.center, ci.radius, 0.0, TAU) {
1238                    for cb in arc_to_cubics(a) {
1239                        if let Curve::Cubic(b) = cb {
1240                            elements.push(PathEl::CubicTo(b.p1, b.p2, b.p3));
1241                        }
1242                    }
1243                }
1244            }
1245        }
1246    }
1247    if closed {
1248        elements.push(PathEl::Close);
1249    }
1250    Path { elements }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256    use core::f64::consts::PI;
1257
1258    #[test]
1259    fn serde_shape_tagging() {
1260        let s = Shape::Line(Segment::new(Point::new(0.0, 0.0), Point::new(1.0, 2.0)));
1261        let j = serde_json::to_string(&s).unwrap();
1262        assert_eq!(j, r#"{"type":"line","a":[0.0,0.0],"b":[1.0,2.0]}"#);
1263        let back: Shape = serde_json::from_str(&j).unwrap();
1264        assert_eq!(back, s);
1265        let p = Shape::Path(Path {
1266            elements: vec![
1267                PathEl::MoveTo(Point::ORIGIN),
1268                PathEl::CubicTo(Point::new(1.0, 1.0), Point::new(2.0, 1.0), Point::new(3.0, 0.0)),
1269                PathEl::Close,
1270            ],
1271        });
1272        let j = serde_json::to_string(&p).unwrap();
1273        let back: Shape = serde_json::from_str(&j).unwrap();
1274        assert_eq!(back, p);
1275    }
1276
1277    #[test]
1278    fn rect_rotation_is_strict_by_default() {
1279        let r = Shape::Rect(Rect { origin: Point::ORIGIN, width: 2.0, height: 1.0 });
1280        assert!(r.transform(Affine::rotate(0.3), TransformPolicy::Strict).is_err());
1281        let conv = r.transform(Affine::rotate(PI / 2.0), TransformPolicy::Convert).unwrap();
1282        assert_eq!(conv.kind(), ShapeKind::Polygon);
1283        assert!((conv.area() - 2.0).abs() < 1e-12);
1284        let mirrored = r.transform(Affine::scale(-1.0, 1.0), TransformPolicy::Strict).unwrap();
1285        assert_eq!(mirrored.bbox().min, Point::new(-2.0, 0.0));
1286    }
1287
1288    #[test]
1289    fn circle_nonuniform_scale_policy() {
1290        let c = Shape::Circle(Circle::new(Point::ORIGIN, 1.0).unwrap());
1291        let err = c.transform(Affine::scale(2.0, 1.0), TransformPolicy::Strict);
1292        assert!(matches!(err, Err(GeometryError::UnsupportedTransform { .. })));
1293        let e = c.transform(Affine::scale(2.0, 1.0), TransformPolicy::Convert).unwrap();
1294        assert_eq!(e.kind(), ShapeKind::Path);
1295        // Ellipse area π·a·b = 2π, cubic approximation is within 0.1 %.
1296        assert!((e.area() - 2.0 * PI).abs() / (2.0 * PI) < 1e-3, "{}", e.area());
1297        // Circle anchors are no longer reported as a center anchor after conversion.
1298        assert!(e.anchor("center").is_none());
1299    }
1300
1301    #[test]
1302    fn contains_with_holes() {
1303        let poly = Shape::Polygon(Polygon {
1304            outer: vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)],
1305            holes: vec![vec![Point::new(4.0, 4.0), Point::new(6.0, 4.0), Point::new(6.0, 6.0), Point::new(4.0, 6.0)]],
1306        });
1307        let tol = FlattenTolerance::default();
1308        assert!(poly.contains_point(Point::new(1.0, 1.0), tol));
1309        assert!(!poly.contains_point(Point::new(5.0, 5.0), tol));
1310        assert!(!poly.contains_point(Point::new(11.0, 5.0), tol));
1311        assert!((poly.area() - 96.0).abs() < 1e-12);
1312    }
1313
1314    #[test]
1315    fn polyline_with_bulge_area_and_length() {
1316        // Half disc: diameter from (-1,0) to (1,0) closed by a CCW semicircle.
1317        let p = Shape::Polyline(Polyline {
1318            points: vec![Point::new(-1.0, 0.0), Point::new(1.0, 0.0)],
1319            bulges: vec![0.0, 1.0],
1320            closed: true,
1321        });
1322        assert!((p.area() - PI / 2.0).abs() < 1e-12, "{}", p.area());
1323        assert!((p.length() - (2.0 + PI)).abs() < 1e-12);
1324    }
1325
1326    #[test]
1327    fn crossing_vs_window_selection() {
1328        let l = Shape::Line(Segment::new(Point::new(0.0, 0.0), Point::new(10.0, 0.0)));
1329        let r = Aabb::from_corners(Point::new(4.0, -1.0), Point::new(6.0, 1.0));
1330        assert!(l.intersects_rect(r, ModelTolerance::DEFAULT));
1331        assert!(!l.inside_rect(r));
1332        let big = Aabb::from_corners(Point::new(-1.0, -1.0), Point::new(11.0, 1.0));
1333        assert!(l.inside_rect(big));
1334        let circle = Shape::Circle(Circle::new(Point::ORIGIN, 10.0).unwrap());
1335        let inner = Aabb::from_corners(Point::new(-1.0, -1.0), Point::new(1.0, 1.0));
1336        assert!(circle.intersects_rect(inner, ModelTolerance::DEFAULT));
1337    }
1338
1339    #[test]
1340    fn validate_rejects_bad_input() {
1341        assert!(Shape::Line(Segment::new(Point::new(f64::NAN, 0.0), Point::ORIGIN)).validate().is_err());
1342        assert!(
1343            Shape::Polyline(Polyline {
1344                points: vec![Point::ORIGIN, Point::new(1.0, 0.0)],
1345                bulges: vec![0.1, 0.2],
1346                closed: false
1347            })
1348            .validate()
1349            .is_err()
1350        );
1351        assert!(Shape::Path(Path { elements: vec![PathEl::LineTo(Point::ORIGIN)] }).validate().is_err());
1352        assert!(Shape::Circle(Circle { center: Point::ORIGIN, radius: 0.0 }).validate().is_err());
1353    }
1354
1355    #[test]
1356    fn line_layout_matches_harfbuzz_kerning() {
1357        // Reference widths in font units from HarfBuzz 11 (uharfbuzz 0.56.2) shaping the
1358        // same font with only `kern` enabled — an independent implementation of
1359        // GPOS pair positioning (class pairs, explicit pairs, two lookups).
1360        let cases = [
1361            ("AV", 2686.0),
1362            ("To", 2390.0),
1363            ("Yo", 2461.0),
1364            ("Wa", 3064.0),
1365            ("LT", 2283.0),
1366            ("Ölçü planı — İğdır", 17152.0),
1367            ("TAVERN", 7926.0),
1368            ("P.", 1829.0),
1369            ("f)", 1505.0),
1370            ("Tığ", 3074.0),
1371            ("kv", 2275.0),
1372            ("Hello", 4936.0),
1373            ("AV\tA", 4675.0),
1374        ];
1375        for (s, expected) in cases {
1376            let (pens, width) = Text::line_pens(s);
1377            assert_eq!(width, expected, "{s}");
1378            assert_eq!(pens.len(), s.chars().count(), "{s}");
1379        }
1380        // Kerning really applies: "AV" is narrower than its advances.
1381        let (pens, _) = Text::line_pens("AV");
1382        let a_advance = pens[1].1;
1383        let (_, a_alone) = Text::line_pens("A");
1384        assert!(a_advance < a_alone, "{a_advance} vs {a_alone}");
1385        // Model units scale with the text height; controls are dropped.
1386        let h = 10.0;
1387        assert!((Text::line_width("AV", h) - 2686.0 / 2048.0 * Text::em_size(h)).abs() < 1e-12);
1388        assert_eq!(Text::line_pens("A\u{7}V").1, 2686.0);
1389    }
1390
1391    #[test]
1392    fn text_similarity_only() {
1393        let t = Shape::Text(Text {
1394            position: Point::new(1.0, 1.0),
1395            content: "Ölçü ğüşıİç".into(),
1396            height: 2.5,
1397            rotation: 0.0,
1398            halign: HAlign::Center,
1399            valign: VAlign::Middle,
1400        });
1401        let r = t.transform(Affine::rotate(PI / 2.0).then(Affine::scale(2.0, 2.0)), TransformPolicy::Strict).unwrap();
1402        if let Shape::Text(tx) = r {
1403            assert!((tx.height - 5.0).abs() < 1e-12);
1404            assert!((tx.rotation - PI / 2.0).abs() < 1e-12);
1405        } else {
1406            unreachable!();
1407        }
1408        assert!(t.transform(Affine::scale(2.0, 1.0), TransformPolicy::Convert).is_err());
1409        // Width follows the default font's advances, character by character
1410        // (checked against the font file in dotloom-render).
1411        let b = t.bbox();
1412        let w = Text::line_width("Ölçü ğüşıİç", 2.5);
1413        assert!((b.width() - w).abs() < 1e-9);
1414        assert!(w > 11.0 * 2.5 * 0.4 && w < 11.0 * 2.5 * 0.8, "{w}");
1415    }
1416}