Skip to main content

rscad_core/
sketch.rs

1//! 2D sketch data model: points, entities (lines, circles, arcs), and
2//! constraints, plus closed-profile extraction for the CSG backends.
3//!
4//! A sketch lives in its node's local XZ plane: sketch coordinates `(x, y)`
5//! map to local `(x, 0, y)`, matching how the mesh backends evaluate 2D
6//! primitives. Constraint *solving* lives in the `rscad-sketch` crate (it
7//! needs `std` + ezpz); this module only stores the data and derives closed
8//! profile loops from it.
9
10use glam::DVec2;
11
12/// Stable identifier for sketch points and entities (one shared id space).
13pub type SketchId = u32;
14
15/// Default tessellation density (segments per full circle) used when a
16/// consumer has no better setting.
17pub const DEFAULT_ARC_SEGMENTS: usize = 32;
18
19/// A free point in sketch coordinates.
20#[derive(Clone, Copy, Debug, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct SketchPoint {
23    pub id: SketchId,
24    pub pos: [f64; 2],
25}
26
27/// Geometry carried by a sketch entity.
28#[derive(Clone, Copy, Debug, PartialEq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub enum SketchEntityKind {
31    /// Segment between two points.
32    Line { p0: SketchId, p1: SketchId },
33    /// Full circle around a center point.
34    Circle { center: SketchId, radius: f64 },
35    /// Circular arc from `start` to `end`, counter-clockwise around `center`
36    /// (ezpz's `DatumCircularArc` convention).
37    Arc {
38        center: SketchId,
39        start: SketchId,
40        end: SketchId,
41    },
42}
43
44#[derive(Clone, Copy, Debug, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct SketchEntity {
47    pub id: SketchId,
48    pub kind: SketchEntityKind,
49    /// Reference-only geometry: participates in constraint solving but is
50    /// excluded from profile extraction (and therefore all CSG backends).
51    #[cfg_attr(feature = "serde", serde(default))]
52    pub construction: bool,
53}
54
55/// Geometric constraints between sketch points and entities.
56///
57/// Point-valued fields hold point ids; `line`/`a`/`b` on the line constraints
58/// and `entity` on `Radius`/`Tangent` hold entity ids.
59#[derive(Clone, Copy, Debug, PartialEq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub enum SketchConstraint {
62    /// Two points occupy the same location.
63    Coincident {
64        a: SketchId,
65        b: SketchId,
66    },
67    /// Line is parallel to the sketch X axis.
68    Horizontal {
69        line: SketchId,
70    },
71    /// Line is parallel to the sketch Y axis.
72    Vertical {
73        line: SketchId,
74    },
75    Parallel {
76        a: SketchId,
77        b: SketchId,
78    },
79    Perpendicular {
80        a: SketchId,
81        b: SketchId,
82    },
83    /// Line entity tangent to a circle or arc entity.
84    Tangent {
85        line: SketchId,
86        entity: SketchId,
87    },
88    /// Point lies on a circle or arc entity's perimeter.
89    PointOnCurve {
90        point: SketchId,
91        entity: SketchId,
92    },
93    /// Distance between two points.
94    Distance {
95        a: SketchId,
96        b: SketchId,
97        d: f64,
98    },
99    /// Radius of a circle or arc entity.
100    Radius {
101        entity: SketchId,
102        r: f64,
103    },
104    /// Point locked to a position.
105    Fixed {
106        point: SketchId,
107        x: f64,
108        y: f64,
109    },
110}
111
112impl SketchConstraint {
113    /// Short human-readable label for constraint list UIs.
114    pub fn label(&self) -> &'static str {
115        match self {
116            Self::Coincident { .. } => "Coincident",
117            Self::Horizontal { .. } => "Horizontal",
118            Self::Vertical { .. } => "Vertical",
119            Self::Parallel { .. } => "Parallel",
120            Self::Perpendicular { .. } => "Perpendicular",
121            Self::Tangent { .. } => "Tangent",
122            Self::PointOnCurve { .. } => "On Curve",
123            Self::Distance { .. } => "Distance",
124            Self::Radius { .. } => "Radius",
125            Self::Fixed { .. } => "Fixed",
126        }
127    }
128
129    /// The editable scalar value, if this constraint kind has one.
130    pub fn value(&self) -> Option<f64> {
131        match self {
132            Self::Distance { d, .. } => Some(*d),
133            Self::Radius { r, .. } => Some(*r),
134            _ => None,
135        }
136    }
137
138    /// Set the editable scalar value, if this constraint kind has one.
139    pub fn set_value(&mut self, value: f64) {
140        match self {
141            Self::Distance { d, .. } => *d = value,
142            Self::Radius { r, .. } => *r = value,
143            _ => {}
144        }
145    }
146}
147
148/// A 2D sketch: points, entities, and the constraints between them.
149#[derive(Clone, Debug, Default, PartialEq)]
150#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
151pub struct SketchData {
152    pub points: Vec<SketchPoint>,
153    pub entities: Vec<SketchEntity>,
154    pub constraints: Vec<SketchConstraint>,
155    /// Next unallocated [`SketchId`].
156    #[cfg_attr(feature = "serde", serde(default))]
157    pub next_id: SketchId,
158}
159
160impl SketchData {
161    fn alloc_id(&mut self) -> SketchId {
162        // Heal `next_id` after deserializing files that omitted it.
163        let floor = self
164            .points
165            .iter()
166            .map(|p| p.id)
167            .chain(self.entities.iter().map(|e| e.id))
168            .max()
169            .map_or(0, |m| m + 1);
170        self.next_id = self.next_id.max(floor);
171        let id = self.next_id;
172        self.next_id += 1;
173        id
174    }
175
176    pub fn point(&self, id: SketchId) -> Option<&SketchPoint> {
177        self.points.iter().find(|p| p.id == id)
178    }
179
180    pub fn point_mut(&mut self, id: SketchId) -> Option<&mut SketchPoint> {
181        self.points.iter_mut().find(|p| p.id == id)
182    }
183
184    pub fn point_pos(&self, id: SketchId) -> Option<DVec2> {
185        self.point(id).map(|p| DVec2::from_array(p.pos))
186    }
187
188    pub fn entity(&self, id: SketchId) -> Option<&SketchEntity> {
189        self.entities.iter().find(|e| e.id == id)
190    }
191
192    pub fn entity_mut(&mut self, id: SketchId) -> Option<&mut SketchEntity> {
193        self.entities.iter_mut().find(|e| e.id == id)
194    }
195
196    /// Mark an entity as construction (or geometry again). Returns whether
197    /// the entity was found.
198    pub fn set_construction(&mut self, id: SketchId, construction: bool) -> bool {
199        match self.entity_mut(id) {
200            Some(e) => {
201                e.construction = construction;
202                true
203            }
204            None => false,
205        }
206    }
207
208    // ── Construction ───────────────────────────────────────
209
210    pub fn add_point(&mut self, pos: DVec2) -> SketchId {
211        let id = self.alloc_id();
212        self.points.push(SketchPoint {
213            id,
214            pos: pos.to_array(),
215        });
216        id
217    }
218
219    /// Add a line between two existing points.
220    pub fn add_line(&mut self, p0: SketchId, p1: SketchId) -> SketchId {
221        let id = self.alloc_id();
222        self.entities.push(SketchEntity {
223            id,
224            kind: SketchEntityKind::Line { p0, p1 },
225            construction: false,
226        });
227        id
228    }
229
230    pub fn add_circle(&mut self, center: DVec2, radius: f64) -> SketchId {
231        let center = self.add_point(center);
232        self.add_circle_with_center(center, radius)
233    }
234
235    /// Add a circle about an existing center point.
236    pub fn add_circle_with_center(&mut self, center: SketchId, radius: f64) -> SketchId {
237        let id = self.alloc_id();
238        self.entities.push(SketchEntity {
239            id,
240            kind: SketchEntityKind::Circle { center, radius },
241            construction: false,
242        });
243        id
244    }
245
246    /// Add a counter-clockwise arc through existing points.
247    pub fn add_arc(&mut self, center: SketchId, start: SketchId, end: SketchId) -> SketchId {
248        let id = self.alloc_id();
249        self.entities.push(SketchEntity {
250            id,
251            kind: SketchEntityKind::Arc { center, start, end },
252            construction: false,
253        });
254        id
255    }
256
257    /// Add an axis-aligned rectangle as 4 points + 4 lines with
258    /// horizontal/vertical constraints (the standard sketcher decomposition).
259    pub fn add_rect(&mut self, corner_a: DVec2, corner_b: DVec2) -> [SketchId; 4] {
260        let (min, max) = (corner_a.min(corner_b), corner_a.max(corner_b));
261        let p0 = self.add_point(min);
262        let p1 = self.add_point(DVec2::new(max.x, min.y));
263        let p2 = self.add_point(max);
264        let p3 = self.add_point(DVec2::new(min.x, max.y));
265        let bottom = self.add_line(p0, p1);
266        let right = self.add_line(p1, p2);
267        let top = self.add_line(p2, p3);
268        let left = self.add_line(p3, p0);
269        self.constraints
270            .push(SketchConstraint::Horizontal { line: bottom });
271        self.constraints
272            .push(SketchConstraint::Horizontal { line: top });
273        self.constraints
274            .push(SketchConstraint::Vertical { line: right });
275        self.constraints
276            .push(SketchConstraint::Vertical { line: left });
277        [bottom, right, top, left]
278    }
279
280    // ── Deletion ───────────────────────────────────────────
281
282    /// Delete an entity, cascading to constraints that reference it and to
283    /// points no longer used by any entity.
284    pub fn delete_entity(&mut self, id: SketchId) {
285        self.entities.retain(|e| e.id != id);
286        self.constraints.retain(|c| match c {
287            SketchConstraint::Horizontal { line } | SketchConstraint::Vertical { line } => {
288                *line != id
289            }
290            SketchConstraint::Parallel { a, b } | SketchConstraint::Perpendicular { a, b } => {
291                *a != id && *b != id
292            }
293            SketchConstraint::Tangent { line, entity } => *line != id && *entity != id,
294            SketchConstraint::PointOnCurve { entity, .. } => *entity != id,
295            SketchConstraint::Radius { entity, .. } => *entity != id,
296            _ => true,
297        });
298        self.prune_orphan_points();
299    }
300
301    pub fn delete_constraint(&mut self, index: usize) {
302        if index < self.constraints.len() {
303            self.constraints.remove(index);
304        }
305    }
306
307    /// Delete a point, cascading to entities that use it (as endpoint or
308    /// center) and to constraints that reference it — the entity cascade in
309    /// turn prunes any points and constraints it orphans.
310    pub fn delete_point(&mut self, id: SketchId) {
311        let uses: Vec<SketchId> = self
312            .entities
313            .iter()
314            .filter(|e| match e.kind {
315                SketchEntityKind::Line { p0, p1 } => p0 == id || p1 == id,
316                SketchEntityKind::Circle { center, .. } => center == id,
317                SketchEntityKind::Arc { center, start, end } => {
318                    center == id || start == id || end == id
319                }
320            })
321            .map(|e| e.id)
322            .collect();
323        for entity in uses {
324            self.delete_entity(entity);
325        }
326        // A lone point has no entity cascade to prune it — drop it and its
327        // constraints directly.
328        self.points.retain(|p| p.id != id);
329        self.constraints.retain(|c| match c {
330            SketchConstraint::Coincident { a, b } | SketchConstraint::Distance { a, b, .. } => {
331                *a != id && *b != id
332            }
333            SketchConstraint::Fixed { point, .. }
334            | SketchConstraint::PointOnCurve { point, .. } => *point != id,
335            _ => true,
336        });
337    }
338
339    /// Drop points not referenced by any entity, plus constraints that
340    /// referenced the dropped points.
341    fn prune_orphan_points(&mut self) {
342        let used = |id: SketchId, entities: &[SketchEntity]| {
343            entities.iter().any(|e| match e.kind {
344                SketchEntityKind::Line { p0, p1 } => p0 == id || p1 == id,
345                SketchEntityKind::Circle { center, .. } => center == id,
346                SketchEntityKind::Arc { center, start, end } => {
347                    center == id || start == id || end == id
348                }
349            })
350        };
351        let entities = core::mem::take(&mut self.entities);
352        self.points.retain(|p| used(p.id, &entities));
353        self.entities = entities;
354
355        let alive = |id: SketchId, points: &[SketchPoint]| points.iter().any(|p| p.id == id);
356        let points = core::mem::take(&mut self.points);
357        self.constraints.retain(|c| match c {
358            SketchConstraint::Coincident { a, b } | SketchConstraint::Distance { a, b, .. } => {
359                alive(*a, &points) && alive(*b, &points)
360            }
361            SketchConstraint::Fixed { point, .. }
362            | SketchConstraint::PointOnCurve { point, .. } => alive(*point, &points),
363            _ => true,
364        });
365        self.points = points;
366    }
367
368    // ── Connected components ───────────────────────────────
369
370    /// Group entities into maximally connected shapes.
371    ///
372    /// Two entities belong to the same component when they share a point or
373    /// their points are pinned together by a [`SketchConstraint::Coincident`]
374    /// — the same reachability [`profile_loops`] chains through. Value
375    /// constraints between shapes (`Distance`, `Parallel`, …) do *not* merge
376    /// them. Components are returned in first-appearance order; each lists
377    /// its entity ids in sketch order.
378    pub fn connected_components(&self) -> Vec<Vec<SketchId>> {
379        // Union-find over point ids (path-halving find).
380        let index: std::collections::HashMap<SketchId, usize> = self
381            .points
382            .iter()
383            .enumerate()
384            .map(|(i, p)| (p.id, i))
385            .collect();
386        let mut parent: Vec<usize> = (0..self.points.len()).collect();
387        let union = |parent: &mut [usize], a: SketchId, b: SketchId| {
388            if let (Some(&i), Some(&j)) = (index.get(&a), index.get(&b)) {
389                let (ri, rj) = (uf_find(parent, i), uf_find(parent, j));
390                parent[ri] = rj;
391            }
392        };
393        for entity in &self.entities {
394            let pts = entity_point_ids(entity);
395            for pair in pts.windows(2) {
396                union(&mut parent, pair[0], pair[1]);
397            }
398        }
399        for constraint in &self.constraints {
400            if let SketchConstraint::Coincident { a, b } = constraint {
401                union(&mut parent, *a, *b);
402            }
403        }
404
405        // Group entities by the root of their first resolvable point.
406        let mut components: Vec<Vec<SketchId>> = Vec::new();
407        let mut root_to_component: std::collections::HashMap<usize, usize> =
408            std::collections::HashMap::new();
409        for entity in &self.entities {
410            let root = entity_point_ids(entity)
411                .iter()
412                .find_map(|id| index.get(id).copied())
413                .map(|i| uf_find(&mut parent, i));
414            match root {
415                Some(root) => {
416                    let slot = *root_to_component.entry(root).or_insert_with(|| {
417                        components.push(Vec::new());
418                        components.len() - 1
419                    });
420                    components[slot].push(entity.id);
421                }
422                // Dangling entity (no live points): its own component.
423                None => components.push(vec![entity.id]),
424            }
425        }
426        components
427    }
428
429    /// Clone a profile-extraction view restricted to `entity_ids`: entities
430    /// are filtered (sketch order kept); points, constraints and `next_id`
431    /// are copied verbatim — unused ones are inert for [`profile_loops`].
432    /// Not a real partition; see [`Self::split_off_entities`] for that.
433    pub fn entity_subset(&self, entity_ids: &[SketchId]) -> SketchData {
434        SketchData {
435            points: self.points.clone(),
436            entities: self
437                .entities
438                .iter()
439                .filter(|e| entity_ids.contains(&e.id))
440                .copied()
441                .collect(),
442            constraints: self.constraints.clone(),
443            next_id: self.next_id,
444        }
445    }
446
447    /// Group entities into geometrically independent shapes: the
448    /// [connected components](Self::connected_components), merged
449    /// (transitively) whenever their closed profile loops interact — one
450    /// nested inside the other (it renders as a hole) or boundaries properly
451    /// crossing (the profile is their union) — so extruding one shape never
452    /// tears a hole off its outer. Components without closed loops (open
453    /// chains) never merge. Groups are returned in first-appearance order,
454    /// each listing its entity ids. Construction entities stay in their
455    /// component's group (they travel with the shape they're attached to)
456    /// but contribute no loops, so they never cause a merge on their own.
457    pub fn geometric_components(&self, arc_segments: usize) -> Vec<Vec<SketchId>> {
458        let components = self.connected_components();
459        if components.len() <= 1 {
460            return components;
461        }
462        let loops: Vec<Vec<Vec<DVec2>>> = components
463            .iter()
464            .map(|ids| profile_loops(&self.entity_subset(ids), arc_segments).loops)
465            .collect();
466
467        // Union-find over component indices.
468        let mut parent: Vec<usize> = (0..components.len()).collect();
469        for i in 0..components.len() {
470            for j in (i + 1)..components.len() {
471                let interact = loops[i]
472                    .iter()
473                    .any(|a| loops[j].iter().any(|b| loops_interact(a, b)));
474                if interact {
475                    let (ri, rj) = (uf_find(&mut parent, i), uf_find(&mut parent, j));
476                    parent[ri] = rj;
477                }
478            }
479        }
480
481        // Regroup by root, keeping first-appearance order.
482        let mut groups: Vec<Vec<SketchId>> = Vec::new();
483        let mut root_to_group: std::collections::HashMap<usize, usize> =
484            std::collections::HashMap::new();
485        for (i, ids) in components.into_iter().enumerate() {
486            let root = uf_find(&mut parent, i);
487            let slot = *root_to_group.entry(root).or_insert_with(|| {
488                groups.push(Vec::new());
489                groups.len() - 1
490            });
491            groups[slot].extend(ids);
492        }
493        groups
494    }
495
496    /// Move the given entities — with their points and every constraint fully
497    /// contained in the moved set — out of this sketch into a new one.
498    ///
499    /// Intended for whole components from [`connected_components`], where the
500    /// halves share no points. Constraints referencing both halves (e.g. a
501    /// `Distance` between two shapes) are dropped: they can live in neither.
502    /// `next_id` is carried over so ids stay unique across both halves.
503    pub fn split_off_entities(&mut self, entity_ids: &[SketchId]) -> SketchData {
504        let (taken_entities, rest_entities): (Vec<_>, Vec<_>) = core::mem::take(&mut self.entities)
505            .into_iter()
506            .partition(|e| entity_ids.contains(&e.id));
507        self.entities = rest_entities;
508
509        let taken_point_ids: Vec<SketchId> =
510            taken_entities.iter().flat_map(entity_point_ids).collect();
511        let (taken_points, rest_points): (Vec<_>, Vec<_>) = core::mem::take(&mut self.points)
512            .into_iter()
513            .partition(|p| taken_point_ids.contains(&p.id));
514        self.points = rest_points;
515
516        let point_taken = |id: &SketchId| taken_points.iter().any(|p| p.id == *id);
517        let point_rest = |id: &SketchId| self.points.iter().any(|p| p.id == *id);
518        let entity_taken = |id: &SketchId| taken_entities.iter().any(|e| e.id == *id);
519        let entity_rest = |id: &SketchId| self.entities.iter().any(|e| e.id == *id);
520        let mut taken_constraints = Vec::new();
521        let mut rest_constraints = Vec::new();
522        for c in core::mem::take(&mut self.constraints) {
523            let (points, entities) = constraint_refs(&c);
524            if points.iter().all(point_taken) && entities.iter().all(entity_taken) {
525                taken_constraints.push(c);
526            } else if points.iter().all(point_rest) && entities.iter().all(entity_rest) {
527                rest_constraints.push(c);
528            }
529            // else: spans both halves — dropped.
530        }
531        self.constraints = rest_constraints;
532
533        SketchData {
534            points: taken_points,
535            entities: taken_entities,
536            constraints: taken_constraints,
537            next_id: self.next_id,
538        }
539    }
540}
541
542/// The point ids an entity references (center/endpoints).
543fn entity_point_ids(entity: &SketchEntity) -> Vec<SketchId> {
544    match entity.kind {
545        SketchEntityKind::Line { p0, p1 } => vec![p0, p1],
546        SketchEntityKind::Circle { center, .. } => vec![center],
547        SketchEntityKind::Arc { center, start, end } => vec![center, start, end],
548    }
549}
550
551/// The `(point ids, entity ids)` a constraint references.
552fn constraint_refs(c: &SketchConstraint) -> (Vec<SketchId>, Vec<SketchId>) {
553    match c {
554        SketchConstraint::Coincident { a, b } | SketchConstraint::Distance { a, b, .. } => {
555            (vec![*a, *b], vec![])
556        }
557        SketchConstraint::Horizontal { line } | SketchConstraint::Vertical { line } => {
558            (vec![], vec![*line])
559        }
560        SketchConstraint::Parallel { a, b } | SketchConstraint::Perpendicular { a, b } => {
561            (vec![], vec![*a, *b])
562        }
563        SketchConstraint::Tangent { line, entity } => (vec![], vec![*line, *entity]),
564        SketchConstraint::PointOnCurve { point, entity } => (vec![*point], vec![*entity]),
565        SketchConstraint::Radius { entity, .. } => (vec![], vec![*entity]),
566        SketchConstraint::Fixed { point, .. } => (vec![*point], vec![]),
567    }
568}
569
570/// Path-halving union-find lookup.
571fn uf_find(parent: &mut [usize], mut i: usize) -> usize {
572    while parent[i] != i {
573        parent[i] = parent[parent[i]];
574        i = parent[i];
575    }
576    i
577}
578
579// ── Profile extraction ──────────────────────────────────────────────────────
580
581/// Closed profile loops extracted from a sketch, ready for the CSG backends.
582///
583/// Winding is normalized: outer boundaries are counter-clockwise, holes
584/// clockwise (nesting depth decides which is which, even-odd style).
585#[derive(Clone, Debug, Default, PartialEq)]
586pub struct Profile {
587    pub loops: Vec<Vec<DVec2>>,
588    /// Number of open/branching chains that could not close into loops.
589    pub open_chains: usize,
590}
591
592impl Profile {
593    /// `true` if at least one closed loop was found.
594    pub fn has_loops(&self) -> bool {
595        !self.loops.is_empty()
596    }
597}
598
599/// Extract closed loops from the sketch's lines and arcs (chained through
600/// shared or `Coincident` endpoints) plus standalone circles.
601///
602/// `arc_segments` is the tessellation density for a full circle; arcs get a
603/// proportional share of it.
604pub fn profile_loops(data: &SketchData, arc_segments: usize) -> Profile {
605    let arc_segments = arc_segments.max(8);
606
607    // Union-find over point ids, seeded by Coincident constraints.
608    let mut canon: Vec<(SketchId, SketchId)> = data.points.iter().map(|p| (p.id, p.id)).collect();
609    fn find(canon: &mut Vec<(SketchId, SketchId)>, id: SketchId) -> SketchId {
610        let Some(idx) = canon.iter().position(|(k, _)| *k == id) else {
611            return id;
612        };
613        let parent = canon[idx].1;
614        if parent == id {
615            return id;
616        }
617        let root = find(canon, parent);
618        canon[idx].1 = root;
619        root
620    }
621    for c in &data.constraints {
622        if let SketchConstraint::Coincident { a, b } = c {
623            let (ra, rb) = (find(&mut canon, *a), find(&mut canon, *b));
624            if ra != rb
625                && let Some(idx) = canon.iter().position(|(k, _)| *k == ra)
626            {
627                canon[idx].1 = rb;
628            }
629        }
630    }
631
632    // Edges between canonical endpoints; each edge knows how to emit its
633    // polyline (start vertex first, end vertex excluded).
634    struct Edge {
635        a: SketchId,
636        b: SketchId,
637        entity: SketchId,
638        used: bool,
639    }
640    let mut edges: Vec<Edge> = Vec::new();
641    let mut loops: Vec<Vec<DVec2>> = Vec::new();
642
643    for e in &data.entities {
644        if e.construction {
645            continue;
646        }
647        match e.kind {
648            SketchEntityKind::Line { p0, p1 }
649            | SketchEntityKind::Arc {
650                start: p0, end: p1, ..
651            } => {
652                let (a, b) = (find(&mut canon, p0), find(&mut canon, p1));
653                if a != b {
654                    edges.push(Edge {
655                        a,
656                        b,
657                        entity: e.id,
658                        used: false,
659                    });
660                }
661            }
662            SketchEntityKind::Circle { center, radius } => {
663                if let Some(c) = data.point_pos(center) {
664                    loops.push(tessellate_circle(c, radius, arc_segments));
665                }
666            }
667        }
668    }
669
670    // Degree per canonical vertex over the edges still in play; only
671    // degree-2 vertices chain unambiguously.
672    let degree = |id: SketchId, edges: &[Edge]| {
673        edges
674            .iter()
675            .filter(|e| !e.used && (e.a == id || e.b == id))
676            .count()
677    };
678
679    // A dangling run of edges (terminating at a degree-1 vertex) can never
680    // close a loop, but it inflates the degree of the vertex it attaches to,
681    // which would abort the chain walk through an otherwise closed loop — a
682    // rectangle with a stub line at one corner would lose the rectangle.
683    // Prune dangles to a fixpoint (removing a tip can expose the next edge),
684    // then count each pruned connected run as one open chain.
685    loop {
686        let mut pruned = false;
687        for idx in 0..edges.len() {
688            if !edges[idx].used
689                && (degree(edges[idx].a, &edges) == 1 || degree(edges[idx].b, &edges) == 1)
690            {
691                edges[idx].used = true;
692                pruned = true;
693            }
694        }
695        if !pruned {
696            break;
697        }
698    }
699    let mut open_chains = {
700        let mut chain_canon: Vec<(SketchId, SketchId)> = Vec::new();
701        for e in edges.iter().filter(|e| e.used) {
702            for v in [e.a, e.b] {
703                if !chain_canon.iter().any(|(k, _)| *k == v) {
704                    chain_canon.push((v, v));
705                }
706            }
707        }
708        for e in edges.iter().filter(|e| e.used) {
709            let (ra, rb) = (find(&mut chain_canon, e.a), find(&mut chain_canon, e.b));
710            if ra != rb
711                && let Some(i) = chain_canon.iter().position(|(k, _)| *k == ra)
712            {
713                chain_canon[i].1 = rb;
714            }
715        }
716        let mut roots: Vec<SketchId> = Vec::new();
717        for e in edges.iter().filter(|e| e.used) {
718            let r = find(&mut chain_canon, e.a);
719            if !roots.contains(&r) {
720                roots.push(r);
721            }
722        }
723        roots.len()
724    };
725
726    for start_idx in 0..edges.len() {
727        if edges[start_idx].used {
728            continue;
729        }
730        // Walk from this edge's `a` endpoint until we return or dead-end.
731        let start_vertex = edges[start_idx].a;
732        let mut chain: Vec<(usize, bool)> = Vec::new(); // (edge idx, forward?)
733        let mut cursor = start_vertex;
734        let mut ok = true;
735        loop {
736            let Some(next_idx) = edges.iter().position(|e| {
737                !e.used
738                    && !chain.iter().any(|(i, _)| edges[*i].entity == e.entity)
739                    && (e.a == cursor || e.b == cursor)
740            }) else {
741                ok = false;
742                break;
743            };
744            let forward = edges[next_idx].a == cursor;
745            let far = if forward {
746                edges[next_idx].b
747            } else {
748                edges[next_idx].a
749            };
750            chain.push((next_idx, forward));
751            cursor = far;
752            if cursor == start_vertex {
753                break;
754            }
755            if degree(cursor, &edges) != 2 {
756                ok = false;
757                break;
758            }
759        }
760
761        if !ok || chain.is_empty() {
762            open_chains += 1;
763            // Mark the dead chain used so we don't retry it from every edge.
764            for (idx, _) in &chain {
765                edges[*idx].used = true;
766            }
767            continue;
768        }
769
770        let mut poly: Vec<DVec2> = Vec::new();
771        for (idx, forward) in &chain {
772            edges[*idx].used = true;
773            let entity = data.entity(edges[*idx].entity).expect("BUG: edge entity");
774            emit_edge(data, entity, *forward, arc_segments, &mut poly);
775        }
776        if poly.len() >= 3 {
777            loops.push(poly);
778        }
779    }
780
781    normalize_windings(&mut loops);
782    Profile { loops, open_chains }
783}
784
785/// Append an edge's polyline to `poly`: start vertex included, end excluded.
786fn emit_edge(
787    data: &SketchData,
788    entity: &SketchEntity,
789    forward: bool,
790    arc_segments: usize,
791    poly: &mut Vec<DVec2>,
792) {
793    match entity.kind {
794        SketchEntityKind::Line { p0, p1 } => {
795            let id = if forward { p0 } else { p1 };
796            if let Some(p) = data.point_pos(id) {
797                poly.push(p);
798            }
799        }
800        SketchEntityKind::Arc { center, start, end } => {
801            let (Some(c), Some(s), Some(e)) = (
802                data.point_pos(center),
803                data.point_pos(start),
804                data.point_pos(end),
805            ) else {
806                return;
807            };
808            let pts = tessellate_arc(c, s, e, arc_segments);
809            if forward {
810                poly.extend(pts[..pts.len() - 1].iter().copied());
811            } else {
812                poly.extend(pts[1..].iter().rev().copied());
813            }
814        }
815        SketchEntityKind::Circle { .. } => {}
816    }
817}
818
819/// Tessellate a CCW arc from `start` to `end` around `center`, endpoints
820/// included and placed exactly (chains stay watertight even if the two
821/// radii disagree slightly).
822fn tessellate_arc(center: DVec2, start: DVec2, end: DVec2, arc_segments: usize) -> Vec<DVec2> {
823    let r0 = (start - center).length();
824    let r1 = (end - center).length();
825    let a0 = (start.y - center.y).atan2(start.x - center.x);
826    let tau = core::f64::consts::TAU;
827    let sweep = arc_sweep_angle(center, start, end);
828
829    let n = ((arc_segments as f64 * sweep / tau).ceil() as usize).max(2);
830    (0..=n)
831        .map(|i| {
832            let t = i as f64 / n as f64;
833            match i {
834                0 => start,
835                _ if i == n => end,
836                _ => {
837                    let a = a0 + sweep * t;
838                    let r = r0 + (r1 - r0) * t;
839                    center + DVec2::new(a.cos(), a.sin()) * r
840                }
841            }
842        })
843        .collect()
844}
845
846fn tessellate_circle(center: DVec2, radius: f64, segments: usize) -> Vec<DVec2> {
847    let n = segments.max(8);
848    (0..n)
849        .map(|i| {
850            let a = core::f64::consts::TAU * i as f64 / n as f64;
851            center + DVec2::new(a.cos(), a.sin()) * radius
852        })
853        .collect()
854}
855
856/// Signed loop area (positive = counter-clockwise = outer boundary after
857/// [`profile_loops`] normalization).
858pub fn signed_area(poly: &[DVec2]) -> f64 {
859    signed_area_2x(poly) / 2.0
860}
861
862/// Twice the signed area (positive = counter-clockwise).
863fn signed_area_2x(poly: &[DVec2]) -> f64 {
864    poly.iter()
865        .zip(poly.iter().cycle().skip(1))
866        .map(|(a, b)| a.x * b.y - b.x * a.y)
867        .take(poly.len())
868        .sum()
869}
870
871fn point_in_polygon(p: DVec2, poly: &[DVec2]) -> bool {
872    let mut inside = false;
873    let n = poly.len();
874    for i in 0..n {
875        let (a, b) = (poly[i], poly[(i + 1) % n]);
876        if (a.y > p.y) != (b.y > p.y) {
877            let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
878            if p.x < x {
879                inside = !inside;
880            }
881        }
882    }
883    inside
884}
885
886/// `(min, max)` corners of a polyline's axis-aligned bounding box.
887fn loop_aabb(poly: &[DVec2]) -> (DVec2, DVec2) {
888    poly.iter().fold(
889        (DVec2::splat(f64::INFINITY), DVec2::splat(f64::NEG_INFINITY)),
890        |(min, max), p| (min.min(*p), max.max(*p)),
891    )
892}
893
894/// Orientation sign of `c` relative to the directed segment `a -> b`
895/// (positive = left of it).
896fn orient(a: DVec2, b: DVec2, c: DVec2) -> f64 {
897    (b - a).perp_dot(c - a)
898}
899
900/// Strict proper crossing: the segments' interiors intersect at a single
901/// point. Any zero orientation — shared endpoints, T-junctions, tangency,
902/// collinear overlap — is *not* a crossing, so touching boundaries keep the
903/// containment treatment.
904fn segments_properly_intersect(a0: DVec2, a1: DVec2, b0: DVec2, b1: DVec2) -> bool {
905    orient(b0, b1, a0) * orient(b0, b1, a1) < 0.0 && orient(a0, a1, b0) * orient(a0, a1, b1) < 0.0
906}
907
908/// Whether two closed loops' boundaries properly cross anywhere.
909fn loops_cross(a: &[DVec2], b: &[DVec2]) -> bool {
910    let ((amin, amax), (bmin, bmax)) = (loop_aabb(a), loop_aabb(b));
911    if amin.x > bmax.x || bmin.x > amax.x || amin.y > bmax.y || bmin.y > amax.y {
912        return false;
913    }
914    (0..a.len()).any(|i| {
915        let (a0, a1) = (a[i], a[(i + 1) % a.len()]);
916        (0..b.len()).any(|j| segments_properly_intersect(a0, a1, b[j], b[(j + 1) % b.len()]))
917    })
918}
919
920/// Whether two loops belong to one profile region: boundaries properly cross,
921/// or one is nested in the other. Containment is all-or-nothing when the
922/// boundaries don't cross, so the first vertex decides (an on-boundary first
923/// vertex inherits the half-open [`point_in_polygon`] answer).
924fn loops_interact(a: &[DVec2], b: &[DVec2]) -> bool {
925    loops_cross(a, b) || point_in_polygon(a[0], b) || point_in_polygon(b[0], a)
926}
927
928/// Orient loops by nesting depth: even depth (outers) CCW, odd (holes) CW.
929///
930/// Depth counts only a loop's *non-crossing* containers, so a loop's role is
931/// decided by the loops that fully enclose it:
932/// - Two crossing top-level loops both stay CCW — positive-fill backends
933///   render their union.
934/// - Crossing loops nested at the same odd depth become one merged cutout.
935/// - A loop nested in an outer while crossing that outer's hole stays a
936///   cutout (the nested role wins; overlapping cutouts merge).
937///
938/// Arrangements without crossings behave as plain even-odd nesting.
939fn normalize_windings(loops: &mut [Vec<DVec2>]) {
940    let n = loops.len();
941    let mut crosses = vec![false; n * n];
942    for i in 0..n {
943        for j in (i + 1)..n {
944            if loops_cross(&loops[i], &loops[j]) {
945                crosses[i * n + j] = true;
946                crosses[j * n + i] = true;
947            }
948        }
949    }
950    let representatives: Vec<DVec2> = loops.iter().map(|l| l[0]).collect();
951    let depths: Vec<usize> = representatives
952        .iter()
953        .enumerate()
954        .map(|(i, &p)| {
955            loops
956                .iter()
957                .enumerate()
958                .filter(|(j, other)| *j != i && !crosses[i * n + j] && point_in_polygon(p, other))
959                .count()
960        })
961        .collect();
962    for (poly, depth) in loops.iter_mut().zip(depths) {
963        let ccw = signed_area_2x(poly) > 0.0;
964        let want_ccw = depth % 2 == 0;
965        if ccw != want_ccw {
966            poly.reverse();
967        }
968    }
969}
970
971// ── Splitting ───────────────────────────────────────────────────────────────
972
973/// An intersection between two entities, as found by
974/// [`SketchData::nearest_intersection`].
975#[derive(Clone, Copy, Debug, PartialEq)]
976pub struct SketchIntersection {
977    pub a: SketchId,
978    pub b: SketchId,
979    pub pos: DVec2,
980}
981
982/// Proper-crossing point of two segments. Same policy as
983/// [`segments_properly_intersect`]: interiors only — shared endpoints,
984/// T-junctions, tangency and collinear overlap yield `None`.
985fn segment_segment_intersection(a0: DVec2, a1: DVec2, b0: DVec2, b1: DVec2) -> Option<DVec2> {
986    if !segments_properly_intersect(a0, a1, b0, b1) {
987        return None;
988    }
989    // Proper crossing ⇒ not parallel ⇒ the denominator is non-zero.
990    let t = (b0 - a0).perp_dot(b1 - b0) / (a1 - a0).perp_dot(b1 - b0);
991    Some(a0 + (a1 - a0) * t)
992}
993
994/// Points where a segment's interior crosses a circle: quadratic roots with
995/// `t` strictly inside `(0, 1)`, ordered by `t`. A grazing (tangent) contact
996/// yields nothing, mirroring the proper-crossing policy for segments.
997fn segment_circle_intersections(a0: DVec2, a1: DVec2, center: DVec2, radius: f64) -> Vec<DVec2> {
998    let d = a1 - a0;
999    let f = a0 - center;
1000    let a = d.length_squared();
1001    if a == 0.0 {
1002        return Vec::new();
1003    }
1004    let b = 2.0 * f.dot(d);
1005    let c = f.length_squared() - radius * radius;
1006    // World-space root separation is √disc / √a: require it to clear a
1007    // radius-scaled epsilon so a graze never counts as a crossing.
1008    let eps = 1e-9 * radius.max(1.0);
1009    let disc = b * b - 4.0 * a * c;
1010    if disc <= a * eps * eps {
1011        return Vec::new();
1012    }
1013    let sqrt_disc = disc.sqrt();
1014    [(-b - sqrt_disc) / (2.0 * a), (-b + sqrt_disc) / (2.0 * a)]
1015        .into_iter()
1016        .filter(|t| *t > 0.0 && *t < 1.0)
1017        .map(|t| a0 + d * t)
1018        .collect()
1019}
1020
1021/// CCW sweep angle from `start` to `end` around `center`, in `(0, τ]`.
1022fn arc_sweep_angle(center: DVec2, start: DVec2, end: DVec2) -> f64 {
1023    let a0 = (start.y - center.y).atan2(start.x - center.x);
1024    let a1 = (end.y - center.y).atan2(end.x - center.x);
1025    let tau = core::f64::consts::TAU;
1026    let sweep = ((a1 - a0).rem_euclid(tau) + tau) % tau;
1027    if sweep == 0.0 { tau } else { sweep }
1028}
1029
1030/// CCW angle of `p` past an arc's start, in `[0, τ)`.
1031fn arc_angle_offset(center: DVec2, start: DVec2, p: DVec2) -> f64 {
1032    let a0 = (start.y - center.y).atan2(start.x - center.x);
1033    let ap = (p.y - center.y).atan2(p.x - center.x);
1034    (ap - a0).rem_euclid(core::f64::consts::TAU)
1035}
1036
1037/// Whether `p`'s angle around `center` lies strictly inside the CCW sweep
1038/// from `start` to `end` (hits at the arc's endpoints are excluded).
1039fn arc_contains_angle(center: DVec2, start: DVec2, end: DVec2, p: DVec2) -> bool {
1040    let off = arc_angle_offset(center, start, p);
1041    off > 0.0 && off < arc_sweep_angle(center, start, end)
1042}
1043
1044/// Points where a segment's interior crosses an arc: circle roots at the
1045/// arc's mean radius, filtered to the CCW span, each re-projected onto the
1046/// radius-interpolated ring [`tessellate_arc`] draws.
1047fn segment_arc_intersections(
1048    a0: DVec2,
1049    a1: DVec2,
1050    center: DVec2,
1051    start: DVec2,
1052    end: DVec2,
1053) -> Vec<DVec2> {
1054    let r0 = (start - center).length();
1055    let r1 = (end - center).length();
1056    let sweep = arc_sweep_angle(center, start, end);
1057    segment_circle_intersections(a0, a1, center, (r0 + r1) * 0.5)
1058        .into_iter()
1059        .filter(|p| arc_contains_angle(center, start, end, *p))
1060        .map(|p| {
1061            let off = arc_angle_offset(center, start, p);
1062            let a = (start.y - center.y).atan2(start.x - center.x) + off;
1063            let r = r0 + (r1 - r0) * (off / sweep);
1064            center + DVec2::new(a.cos(), a.sin()) * r
1065        })
1066        .collect()
1067}
1068
1069/// Distance from `p` to the segment `a`-`b`.
1070fn point_segment_distance(p: DVec2, a: DVec2, b: DVec2) -> f64 {
1071    let ab = b - a;
1072    let len_sq = ab.length_squared();
1073    if len_sq == 0.0 {
1074        return (p - a).length();
1075    }
1076    let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
1077    (a + ab * t - p).length()
1078}
1079
1080/// What [`SketchData::split_at`] decided to do with the non-line entity.
1081enum OtherSplit {
1082    Line,
1083    Arc,
1084    /// The circle's second crossing with the line (a circle can only split
1085    /// into arcs at two points).
1086    Circle {
1087        xj: DVec2,
1088    },
1089}
1090
1091impl SketchData {
1092    /// The supported-pair intersection nearest `cursor`, within `tolerance`.
1093    ///
1094    /// Supported pairs: line×line, line×circle, line×arc — construction
1095    /// entities included. O(pairs); sketches are small.
1096    pub fn nearest_intersection(
1097        &self,
1098        cursor: DVec2,
1099        tolerance: f64,
1100    ) -> Option<SketchIntersection> {
1101        let mut best: Option<SketchIntersection> = None;
1102        let mut best_d = tolerance;
1103        for (i, ea) in self.entities.iter().enumerate() {
1104            for eb in &self.entities[i + 1..] {
1105                for pos in self.pair_intersections(ea, eb) {
1106                    let d = (pos - cursor).length();
1107                    if d <= best_d {
1108                        best_d = d;
1109                        best = Some(SketchIntersection {
1110                            a: ea.id,
1111                            b: eb.id,
1112                            pos,
1113                        });
1114                    }
1115                }
1116            }
1117        }
1118        best
1119    }
1120
1121    /// All crossing points between two entities; empty for unsupported pairs
1122    /// (anything without a line) and dangling point references.
1123    fn pair_intersections(&self, a: &SketchEntity, b: &SketchEntity) -> Vec<DVec2> {
1124        let (line, other) = match (a.kind, b.kind) {
1125            (SketchEntityKind::Line { .. }, _) => (a, b),
1126            (_, SketchEntityKind::Line { .. }) => (b, a),
1127            _ => return Vec::new(),
1128        };
1129        let SketchEntityKind::Line { p0, p1 } = line.kind else {
1130            return Vec::new();
1131        };
1132        let (Some(l0), Some(l1)) = (self.point_pos(p0), self.point_pos(p1)) else {
1133            return Vec::new();
1134        };
1135        match other.kind {
1136            SketchEntityKind::Line { p0, p1 } => {
1137                let (Some(m0), Some(m1)) = (self.point_pos(p0), self.point_pos(p1)) else {
1138                    return Vec::new();
1139                };
1140                segment_segment_intersection(l0, l1, m0, m1)
1141                    .into_iter()
1142                    .collect()
1143            }
1144            SketchEntityKind::Circle { center, radius } => {
1145                let Some(c) = self.point_pos(center) else {
1146                    return Vec::new();
1147                };
1148                segment_circle_intersections(l0, l1, c, radius)
1149            }
1150            SketchEntityKind::Arc { center, start, end } => {
1151                let (Some(c), Some(s), Some(e)) = (
1152                    self.point_pos(center),
1153                    self.point_pos(start),
1154                    self.point_pos(end),
1155                ) else {
1156                    return Vec::new();
1157                };
1158                segment_arc_intersections(l0, l1, c, s, e)
1159            }
1160        }
1161    }
1162
1163    /// Split entities `a` and `b` at their intersection nearest `pos`.
1164    ///
1165    /// Exactly one shared point is created at the split, so the fragments
1166    /// stay joined. First fragments keep the original entity ids (constraint,
1167    /// selection and hover references stay valid); new fragments get fresh
1168    /// ids and inherit `construction`. A fragment shorter than `tolerance`
1169    /// is not created: that entity is left whole, while the other may still
1170    /// split. A circle needs the line to cross it twice (a secant); it
1171    /// becomes two arcs — one boundary per crossing — while the line still
1172    /// splits only at the crossing nearest `pos`. Constraints referencing a
1173    /// split entity are retargeted: direction/radius constraints are
1174    /// duplicated onto both fragments; `Tangent`/`PointOnCurve` move to the
1175    /// fragment nearest their referenced geometry.
1176    ///
1177    /// Returns the newly created entity ids; the sketch is untouched on
1178    /// `Err`.
1179    pub fn split_at(
1180        &mut self,
1181        a: SketchId,
1182        b: SketchId,
1183        pos: DVec2,
1184        tolerance: f64,
1185    ) -> Result<Vec<SketchId>, &'static str> {
1186        let ea = *self.entity(a).ok_or("unknown entity")?;
1187        let eb = *self.entity(b).ok_or("unknown entity")?;
1188        let (line_e, other_e) = match (ea.kind, eb.kind) {
1189            (SketchEntityKind::Line { .. }, _) => (ea, eb),
1190            (_, SketchEntityKind::Line { .. }) => (eb, ea),
1191            _ => return Err("unsupported entity pair"),
1192        };
1193        let SketchEntityKind::Line { p0: lp0, p1: lp1 } = line_e.kind else {
1194            return Err("unsupported entity pair");
1195        };
1196
1197        let hits = self.pair_intersections(&line_e, &other_e);
1198        let xi = *hits
1199            .iter()
1200            .min_by(|p, q| (**p - pos).length().total_cmp(&(**q - pos).length()))
1201            .ok_or("no intersection")?;
1202
1203        let far = |p: Option<DVec2>| p.is_some_and(|p| (xi - p).length() > tolerance);
1204        let line_splits = far(self.point_pos(lp0)) && far(self.point_pos(lp1));
1205        let other_plan = match other_e.kind {
1206            SketchEntityKind::Line { p0, p1 } => {
1207                (far(self.point_pos(p0)) && far(self.point_pos(p1))).then_some(OtherSplit::Line)
1208            }
1209            SketchEntityKind::Arc { start, end, .. } => {
1210                (far(self.point_pos(start)) && far(self.point_pos(end))).then_some(OtherSplit::Arc)
1211            }
1212            SketchEntityKind::Circle { .. } => {
1213                (hits.len() == 2 && (hits[0] - hits[1]).length() > tolerance).then(|| {
1214                    let xj = if (hits[0] - xi).length() < (hits[1] - xi).length() {
1215                        hits[1]
1216                    } else {
1217                        hits[0]
1218                    };
1219                    OtherSplit::Circle { xj }
1220                })
1221            }
1222        };
1223        if !line_splits && other_plan.is_none() {
1224            return Err("intersection too close to endpoints");
1225        }
1226
1227        let x = self.add_point(xi);
1228        let mut new_ids = Vec::new();
1229        let mut splits: Vec<(SketchId, SketchId)> = Vec::new(); // (old, new)
1230
1231        if line_splits {
1232            if let Some(e) = self.entity_mut(line_e.id) {
1233                e.kind = SketchEntityKind::Line { p0: lp0, p1: x };
1234            }
1235            let id = self.alloc_id();
1236            self.entities.push(SketchEntity {
1237                id,
1238                kind: SketchEntityKind::Line { p0: x, p1: lp1 },
1239                construction: line_e.construction,
1240            });
1241            new_ids.push(id);
1242            splits.push((line_e.id, id));
1243        }
1244        match (other_plan, other_e.kind) {
1245            (Some(OtherSplit::Line), SketchEntityKind::Line { p0, p1 }) => {
1246                if let Some(e) = self.entity_mut(other_e.id) {
1247                    e.kind = SketchEntityKind::Line { p0, p1: x };
1248                }
1249                let id = self.alloc_id();
1250                self.entities.push(SketchEntity {
1251                    id,
1252                    kind: SketchEntityKind::Line { p0: x, p1 },
1253                    construction: other_e.construction,
1254                });
1255                new_ids.push(id);
1256                splits.push((other_e.id, id));
1257            }
1258            (Some(OtherSplit::Arc), SketchEntityKind::Arc { center, start, end }) => {
1259                if let Some(e) = self.entity_mut(other_e.id) {
1260                    e.kind = SketchEntityKind::Arc {
1261                        center,
1262                        start,
1263                        end: x,
1264                    };
1265                }
1266                let id = self.alloc_id();
1267                self.entities.push(SketchEntity {
1268                    id,
1269                    kind: SketchEntityKind::Arc {
1270                        center,
1271                        start: x,
1272                        end,
1273                    },
1274                    construction: other_e.construction,
1275                });
1276                new_ids.push(id);
1277                splits.push((other_e.id, id));
1278            }
1279            (Some(OtherSplit::Circle { xj }), SketchEntityKind::Circle { center, .. }) => {
1280                let xj_id = self.add_point(xj);
1281                if let Some(e) = self.entity_mut(other_e.id) {
1282                    e.kind = SketchEntityKind::Arc {
1283                        center,
1284                        start: x,
1285                        end: xj_id,
1286                    };
1287                }
1288                let id = self.alloc_id();
1289                self.entities.push(SketchEntity {
1290                    id,
1291                    kind: SketchEntityKind::Arc {
1292                        center,
1293                        start: xj_id,
1294                        end: x,
1295                    },
1296                    construction: other_e.construction,
1297                });
1298                new_ids.push(id);
1299                splits.push((other_e.id, id));
1300            }
1301            _ => {}
1302        }
1303
1304        for (old, new) in splits {
1305            self.retarget_split_constraints(old, new);
1306        }
1307        Ok(new_ids)
1308    }
1309
1310    /// After `old` split into (`old`, `new`): duplicate direction/radius
1311    /// constraints onto both fragments and move locus constraints
1312    /// (`Tangent`, `PointOnCurve`) to the fragment nearest their referenced
1313    /// geometry.
1314    fn retarget_split_constraints(&mut self, old: SketchId, new: SketchId) {
1315        let mut constraints = core::mem::take(&mut self.constraints);
1316        let mut additions: Vec<SketchConstraint> = Vec::new();
1317        for c in &mut constraints {
1318            match c {
1319                SketchConstraint::Horizontal { line } if *line == old => {
1320                    additions.push(SketchConstraint::Horizontal { line: new });
1321                }
1322                SketchConstraint::Vertical { line } if *line == old => {
1323                    additions.push(SketchConstraint::Vertical { line: new });
1324                }
1325                SketchConstraint::Parallel { a, b } => {
1326                    if *a == old {
1327                        additions.push(SketchConstraint::Parallel { a: new, b: *b });
1328                    }
1329                    if *b == old {
1330                        additions.push(SketchConstraint::Parallel { a: *a, b: new });
1331                    }
1332                }
1333                SketchConstraint::Perpendicular { a, b } => {
1334                    if *a == old {
1335                        additions.push(SketchConstraint::Perpendicular { a: new, b: *b });
1336                    }
1337                    if *b == old {
1338                        additions.push(SketchConstraint::Perpendicular { a: *a, b: new });
1339                    }
1340                }
1341                SketchConstraint::Radius { entity, r } if *entity == old => {
1342                    additions.push(SketchConstraint::Radius { entity: new, r: *r });
1343                }
1344                SketchConstraint::Tangent { line, entity } => {
1345                    if *line == old
1346                        && let Some(target) = self.entity_center_pos(*entity)
1347                        && self.entity_distance_to(new, target)
1348                            < self.entity_distance_to(old, target)
1349                    {
1350                        *line = new;
1351                    }
1352                    if *entity == old
1353                        && let Some(target) = self.closest_point_on_line_entity(*line, old)
1354                        && self.entity_distance_to(new, target)
1355                            < self.entity_distance_to(old, target)
1356                    {
1357                        *entity = new;
1358                    }
1359                }
1360                SketchConstraint::PointOnCurve { point, entity } if *entity == old => {
1361                    if let Some(target) = self.point_pos(*point)
1362                        && self.entity_distance_to(new, target)
1363                            < self.entity_distance_to(old, target)
1364                    {
1365                        *entity = new;
1366                    }
1367                }
1368                _ => {}
1369            }
1370        }
1371        constraints.extend(additions);
1372        self.constraints = constraints;
1373    }
1374
1375    /// Center position of a circle/arc entity (`None` for lines).
1376    fn entity_center_pos(&self, id: SketchId) -> Option<DVec2> {
1377        match self.entity(id)?.kind {
1378            SketchEntityKind::Circle { center, .. } | SketchEntityKind::Arc { center, .. } => {
1379                self.point_pos(center)
1380            }
1381            SketchEntityKind::Line { .. } => None,
1382        }
1383    }
1384
1385    /// Closest point of a line entity's segment to the center of the given
1386    /// circle/arc entity — the natural tangency foot for retargeting.
1387    fn closest_point_on_line_entity(&self, line: SketchId, curve: SketchId) -> Option<DVec2> {
1388        let SketchEntityKind::Line { p0, p1 } = self.entity(line)?.kind else {
1389            return None;
1390        };
1391        let (a, b) = (self.point_pos(p0)?, self.point_pos(p1)?);
1392        let c = self.entity_center_pos(curve)?;
1393        let ab = b - a;
1394        let len_sq = ab.length_squared();
1395        if len_sq == 0.0 {
1396            return Some(a);
1397        }
1398        let t = ((c - a).dot(ab) / len_sq).clamp(0.0, 1.0);
1399        Some(a + ab * t)
1400    }
1401
1402    /// Distance from `p` to an entity's curve (`f64::INFINITY` when the
1403    /// entity or its points are missing).
1404    fn entity_distance_to(&self, id: SketchId, p: DVec2) -> f64 {
1405        let Some(e) = self.entity(id) else {
1406            return f64::INFINITY;
1407        };
1408        match e.kind {
1409            SketchEntityKind::Line { p0, p1 } => match (self.point_pos(p0), self.point_pos(p1)) {
1410                (Some(a), Some(b)) => point_segment_distance(p, a, b),
1411                _ => f64::INFINITY,
1412            },
1413            SketchEntityKind::Circle { center, radius } => match self.point_pos(center) {
1414                Some(c) => ((p - c).length() - radius).abs(),
1415                None => f64::INFINITY,
1416            },
1417            SketchEntityKind::Arc { center, start, end } => {
1418                let (Some(c), Some(s), Some(e)) = (
1419                    self.point_pos(center),
1420                    self.point_pos(start),
1421                    self.point_pos(end),
1422                ) else {
1423                    return f64::INFINITY;
1424                };
1425                if arc_contains_angle(c, s, e, p) {
1426                    let r0 = (s - c).length();
1427                    let r1 = (e - c).length();
1428                    let t = arc_angle_offset(c, s, p) / arc_sweep_angle(c, s, e);
1429                    ((p - c).length() - (r0 + (r1 - r0) * t)).abs()
1430                } else {
1431                    (p - s).length().min((p - e).length())
1432                }
1433            }
1434        }
1435    }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441
1442    fn rect_sketch() -> SketchData {
1443        let mut data = SketchData::default();
1444        data.add_rect(DVec2::ZERO, DVec2::new(2.0, 1.0));
1445        data
1446    }
1447
1448    #[test]
1449    fn rect_forms_one_ccw_loop() {
1450        let profile = profile_loops(&rect_sketch(), 32);
1451        assert_eq!(profile.loops.len(), 1);
1452        assert_eq!(profile.open_chains, 0);
1453        assert_eq!(profile.loops[0].len(), 4);
1454        assert!(signed_area_2x(&profile.loops[0]) > 0.0, "outer must be CCW");
1455        assert!((signed_area_2x(&profile.loops[0]).abs() / 2.0 - 2.0).abs() < 1e-9);
1456    }
1457
1458    #[test]
1459    fn circle_inside_rect_becomes_cw_hole() {
1460        let mut data = rect_sketch();
1461        data.add_circle(DVec2::new(1.0, 0.5), 0.25);
1462        let profile = profile_loops(&data, 32);
1463        assert_eq!(profile.loops.len(), 2);
1464        let (outer, hole) = if profile.loops[0].len() == 4 {
1465            (&profile.loops[0], &profile.loops[1])
1466        } else {
1467            (&profile.loops[1], &profile.loops[0])
1468        };
1469        assert!(signed_area_2x(outer) > 0.0);
1470        assert!(signed_area_2x(hole) < 0.0, "hole must be CW");
1471    }
1472
1473    #[test]
1474    fn open_chain_is_skipped_and_counted() {
1475        let mut data = SketchData::default();
1476        let a = data.add_point(DVec2::ZERO);
1477        let b = data.add_point(DVec2::new(1.0, 0.0));
1478        let c = data.add_point(DVec2::new(1.0, 1.0));
1479        data.add_line(a, b);
1480        data.add_line(b, c);
1481        let profile = profile_loops(&data, 32);
1482        assert!(profile.loops.is_empty());
1483        assert_eq!(profile.open_chains, 1);
1484    }
1485
1486    #[test]
1487    fn delete_point_cascades_and_removes_orphans() {
1488        // A lone (orphan) point — e.g. the Line tool's first tap, abandoned —
1489        // deletes directly along with its constraints.
1490        let mut data = SketchData::default();
1491        let lone = data.add_point(DVec2::ZERO);
1492        data.constraints.push(SketchConstraint::Fixed {
1493            point: lone,
1494            x: 0.0,
1495            y: 0.0,
1496        });
1497        data.delete_point(lone);
1498        assert!(data.points.is_empty());
1499        assert!(data.constraints.is_empty());
1500
1501        // An entity endpoint cascades: the line goes, its other endpoint
1502        // prunes, and constraints on both are dropped.
1503        let mut data = SketchData::default();
1504        let a = data.add_point(DVec2::ZERO);
1505        let b = data.add_point(DVec2::new(1.0, 0.0));
1506        let line = data.add_line(a, b);
1507        data.constraints.push(SketchConstraint::Horizontal { line });
1508        data.constraints
1509            .push(SketchConstraint::Distance { a, b, d: 1.0 });
1510        data.delete_point(a);
1511        assert!(data.points.is_empty());
1512        assert!(data.entities.is_empty());
1513        assert!(data.constraints.is_empty());
1514
1515        // Unrelated geometry survives.
1516        let mut data = SketchData::default();
1517        let a = data.add_point(DVec2::ZERO);
1518        let b = data.add_point(DVec2::new(1.0, 0.0));
1519        data.add_line(a, b);
1520        let lone = data.add_point(DVec2::new(5.0, 5.0));
1521        data.delete_point(lone);
1522        assert_eq!(data.points.len(), 2);
1523        assert_eq!(data.entities.len(), 1);
1524    }
1525
1526    #[test]
1527    fn loop_survives_dangling_line_at_any_corner() {
1528        // A dangling stub raises its corner's degree to 3; the rectangle
1529        // loop must still be found no matter which corner carries the stub.
1530        let corners = [
1531            DVec2::ZERO,
1532            DVec2::new(2.0, 0.0),
1533            DVec2::new(2.0, 1.0),
1534            DVec2::new(0.0, 1.0),
1535        ];
1536        for corner in 0..4 {
1537            let mut data = SketchData::default();
1538            let pts: Vec<SketchId> = corners.iter().map(|&c| data.add_point(c)).collect();
1539            for i in 0..4 {
1540                data.add_line(pts[i], pts[(i + 1) % 4]);
1541            }
1542            let tip = data.add_point(DVec2::new(3.0, 3.0));
1543            data.add_line(pts[corner], tip);
1544            let profile = profile_loops(&data, 32);
1545            assert_eq!(profile.loops.len(), 1, "corner {corner}");
1546            assert_eq!(profile.open_chains, 1, "corner {corner}");
1547            assert!((signed_area_2x(&profile.loops[0]).abs() / 2.0 - 2.0).abs() < 1e-9);
1548        }
1549    }
1550
1551    #[test]
1552    fn loop_survives_multi_edge_dangle() {
1553        // A dangle of two chained segments: pruning must cascade inward
1554        // from the free tip.
1555        let mut data = SketchData::default();
1556        let corners = [
1557            DVec2::ZERO,
1558            DVec2::new(2.0, 0.0),
1559            DVec2::new(2.0, 1.0),
1560            DVec2::new(0.0, 1.0),
1561        ];
1562        let pts: Vec<SketchId> = corners.iter().map(|&c| data.add_point(c)).collect();
1563        for i in 0..4 {
1564            data.add_line(pts[i], pts[(i + 1) % 4]);
1565        }
1566        let mid = data.add_point(DVec2::new(3.0, 1.0));
1567        let tip = data.add_point(DVec2::new(4.0, 2.0));
1568        data.add_line(pts[2], mid);
1569        data.add_line(mid, tip);
1570        let profile = profile_loops(&data, 32);
1571        assert_eq!(profile.loops.len(), 1);
1572        assert_eq!(profile.open_chains, 1);
1573    }
1574
1575    #[test]
1576    fn coincident_endpoints_close_a_loop() {
1577        let mut data = SketchData::default();
1578        // Triangle drawn as three disconnected segments, glued by Coincident.
1579        let a0 = data.add_point(DVec2::ZERO);
1580        let b0 = data.add_point(DVec2::new(2.0, 0.0));
1581        let b1 = data.add_point(DVec2::new(2.0, 0.0));
1582        let c0 = data.add_point(DVec2::new(1.0, 1.5));
1583        let c1 = data.add_point(DVec2::new(1.0, 1.5));
1584        let a1 = data.add_point(DVec2::ZERO);
1585        data.add_line(a0, b0);
1586        data.add_line(b1, c0);
1587        data.add_line(c1, a1);
1588        data.constraints
1589            .push(SketchConstraint::Coincident { a: b0, b: b1 });
1590        data.constraints
1591            .push(SketchConstraint::Coincident { a: c0, b: c1 });
1592        data.constraints
1593            .push(SketchConstraint::Coincident { a: a1, b: a0 });
1594        let profile = profile_loops(&data, 32);
1595        assert_eq!(profile.loops.len(), 1);
1596        assert_eq!(profile.open_chains, 0);
1597        assert_eq!(profile.loops[0].len(), 3);
1598    }
1599
1600    #[test]
1601    fn arc_edges_tessellate_into_the_loop() {
1602        let mut data = SketchData::default();
1603        // Half-disc: line across the diameter + CCW arc back over the top.
1604        let a = data.add_point(DVec2::new(-1.0, 0.0));
1605        let b = data.add_point(DVec2::new(1.0, 0.0));
1606        let center = data.add_point(DVec2::ZERO);
1607        data.add_line(a, b);
1608        data.add_arc(center, b, a);
1609        let profile = profile_loops(&data, 32);
1610        assert_eq!(profile.loops.len(), 1);
1611        assert_eq!(profile.open_chains, 0);
1612        assert!(profile.loops[0].len() > 10, "arc should tessellate");
1613        // Area of a half-disc r=1 is π/2 ≈ 1.571.
1614        let area = signed_area_2x(&profile.loops[0]).abs() / 2.0;
1615        assert!((area - core::f64::consts::FRAC_PI_2).abs() < 0.05, "{area}");
1616    }
1617
1618    #[test]
1619    fn connected_components_split_by_shared_points_and_coincidence() {
1620        let mut data = SketchData::default();
1621        // Shape 1: rect (4 lines sharing corner points).
1622        let rect = data.add_rect(DVec2::ZERO, DVec2::new(2.0, 1.0));
1623        // Shape 2: standalone circle.
1624        let circle = data.add_circle(DVec2::new(5.0, 5.0), 1.0);
1625        // Shape 3: two disjoint-point lines pinned together by Coincident.
1626        let a0 = data.add_point(DVec2::new(8.0, 0.0));
1627        let a1 = data.add_point(DVec2::new(9.0, 0.0));
1628        let b0 = data.add_point(DVec2::new(9.0, 0.0));
1629        let b1 = data.add_point(DVec2::new(9.0, 1.0));
1630        let la = data.add_line(a0, a1);
1631        let lb = data.add_line(b0, b1);
1632        data.constraints
1633            .push(SketchConstraint::Coincident { a: a1, b: b0 });
1634        // A cross-shape value constraint must NOT merge components.
1635        data.constraints.push(SketchConstraint::Distance {
1636            a: a0,
1637            b: data.points[0].id,
1638            d: 8.0,
1639        });
1640
1641        let components = data.connected_components();
1642        assert_eq!(components.len(), 3);
1643        assert_eq!(components[0], rect.to_vec());
1644        assert_eq!(components[1], vec![circle]);
1645        assert_eq!(components[2], vec![la, lb]);
1646    }
1647
1648    #[test]
1649    fn split_off_entities_moves_shape_and_drops_cross_constraints() {
1650        let mut data = SketchData::default();
1651        let rect = data.add_rect(DVec2::ZERO, DVec2::new(2.0, 1.0));
1652        let circle = data.add_circle(DVec2::new(5.0, 5.0), 1.0);
1653        // Cross-shape constraint: dropped by the split.
1654        data.constraints.push(SketchConstraint::Distance {
1655            a: data.points[0].id,
1656            b: data.points[4].id, // circle center
1657            d: 5.0,
1658        });
1659        let next_id = data.next_id;
1660        let n_constraints = data.constraints.len();
1661
1662        let taken = data.split_off_entities(&rect);
1663        assert_eq!(taken.entities.len(), 4);
1664        assert_eq!(taken.points.len(), 4);
1665        // The rect's 4 H/V constraints moved with it.
1666        assert_eq!(taken.constraints.len(), 4);
1667        assert_eq!(taken.next_id, next_id);
1668
1669        assert_eq!(data.entities.len(), 1);
1670        assert_eq!(data.entities[0].id, circle);
1671        assert_eq!(data.points.len(), 1);
1672        // Rest lost the rect constraints AND the cross-shape distance.
1673        assert_eq!(data.constraints.len(), n_constraints - 4 - 1);
1674        assert_eq!(data.next_id, next_id);
1675
1676        // Both halves still produce their own closed profiles.
1677        assert_eq!(profile_loops(&taken, 16).loops.len(), 1);
1678        assert_eq!(profile_loops(&data, 16).loops.len(), 1);
1679    }
1680
1681    #[test]
1682    fn delete_entity_cascades_points_and_constraints() {
1683        let mut data = SketchData::default();
1684        let lines = {
1685            let d = &mut data;
1686            d.add_rect(DVec2::ZERO, DVec2::new(1.0, 1.0))
1687        };
1688        let n_points = data.points.len();
1689        assert_eq!(n_points, 4);
1690        assert_eq!(data.constraints.len(), 4);
1691        data.delete_entity(lines[0]);
1692        assert_eq!(data.entities.len(), 3);
1693        // Both H constraints referenced other lines too; only bottom's went.
1694        assert_eq!(data.constraints.len(), 3);
1695        // All 4 points still used by the remaining 3 lines.
1696        assert_eq!(data.points.len(), 4);
1697        data.delete_entity(lines[1]);
1698        data.delete_entity(lines[2]);
1699        data.delete_entity(lines[3]);
1700        assert!(data.points.is_empty());
1701        assert!(data.constraints.is_empty());
1702    }
1703
1704    #[test]
1705    fn segments_properly_intersect_policy() {
1706        let p = DVec2::new;
1707        // X-cross: interiors intersect.
1708        assert!(segments_properly_intersect(
1709            p(0.0, 0.0),
1710            p(2.0, 2.0),
1711            p(0.0, 2.0),
1712            p(2.0, 0.0)
1713        ));
1714        // Shared endpoint is not a crossing.
1715        assert!(!segments_properly_intersect(
1716            p(0.0, 0.0),
1717            p(2.0, 0.0),
1718            p(2.0, 0.0),
1719            p(3.0, 1.0)
1720        ));
1721        // T-junction (endpoint on interior) is not a crossing.
1722        assert!(!segments_properly_intersect(
1723            p(0.0, 0.0),
1724            p(2.0, 0.0),
1725            p(1.0, 0.0),
1726            p(1.0, 5.0)
1727        ));
1728        // Collinear overlap is not a crossing.
1729        assert!(!segments_properly_intersect(
1730            p(0.0, 0.0),
1731            p(2.0, 0.0),
1732            p(1.0, 0.0),
1733            p(3.0, 0.0)
1734        ));
1735        // Parallel disjoint.
1736        assert!(!segments_properly_intersect(
1737            p(0.0, 0.0),
1738            p(2.0, 0.0),
1739            p(0.0, 1.0),
1740            p(2.0, 1.0)
1741        ));
1742    }
1743
1744    #[test]
1745    fn crossing_loops_normalize_both_ccw() {
1746        let mut data = SketchData::default();
1747        data.add_rect(DVec2::ZERO, DVec2::new(2.0, 2.0));
1748        data.add_rect(DVec2::new(1.0, 1.0), DVec2::new(3.0, 3.0));
1749        let profile = profile_loops(&data, 32);
1750        assert_eq!(profile.loops.len(), 2);
1751        for l in &profile.loops {
1752            assert!(
1753                signed_area_2x(l) > 0.0,
1754                "crossing loops must both stay CCW outers (union)"
1755            );
1756        }
1757    }
1758
1759    #[test]
1760    fn three_level_nesting_alternates_windings() {
1761        let mut data = SketchData::default();
1762        data.add_rect(DVec2::ZERO, DVec2::new(8.0, 8.0));
1763        data.add_rect(DVec2::new(1.0, 1.0), DVec2::new(7.0, 7.0));
1764        data.add_rect(DVec2::new(2.0, 2.0), DVec2::new(6.0, 6.0));
1765        let profile = profile_loops(&data, 32);
1766        assert_eq!(profile.loops.len(), 3);
1767        let mut areas: Vec<f64> = profile.loops.iter().map(|l| signed_area(l)).collect();
1768        areas.sort_by(|a, b| a.abs().partial_cmp(&b.abs()).expect("BUG: NaN area"));
1769        // Inner island (16) CCW, middle hole (36) CW, outer (64) CCW.
1770        assert!(areas[0] > 0.0, "island must be CCW: {areas:?}");
1771        assert!(areas[1] < 0.0, "hole must be CW: {areas:?}");
1772        assert!(areas[2] > 0.0, "outer must be CCW: {areas:?}");
1773    }
1774
1775    #[test]
1776    fn loop_crossing_a_hole_stays_a_cutout() {
1777        let mut data = SketchData::default();
1778        // Outer rect ⊃ { circle hole ⊗ small rect crossing it }.
1779        data.add_rect(DVec2::ZERO, DVec2::new(6.0, 6.0));
1780        data.add_circle(DVec2::new(3.0, 3.0), 1.5);
1781        data.add_rect(DVec2::new(2.75, 2.75), DVec2::new(3.25, 5.0));
1782        let profile = profile_loops(&data, 32);
1783        assert_eq!(profile.loops.len(), 3);
1784        let mut areas: Vec<f64> = profile.loops.iter().map(|l| signed_area(l)).collect();
1785        areas.sort_by(|a, b| a.abs().partial_cmp(&b.abs()).expect("BUG: NaN area"));
1786        // Small rect (1.125) and circle (~7.07) are cutouts, outer (36) fills:
1787        // the nested role wins over the crossing role.
1788        assert!(
1789            areas[0] < 0.0,
1790            "crossing rect must stay a cutout: {areas:?}"
1791        );
1792        assert!(areas[1] < 0.0, "circle hole must be CW: {areas:?}");
1793        assert!(areas[2] > 0.0, "outer must be CCW: {areas:?}");
1794    }
1795
1796    #[test]
1797    fn geometric_components_merges_nested_circle_into_rect() {
1798        let mut data = rect_sketch();
1799        let circle = data.add_circle(DVec2::new(1.0, 0.5), 0.25);
1800        assert_eq!(data.connected_components().len(), 2);
1801        let groups = data.geometric_components(32);
1802        assert_eq!(groups.len(), 1);
1803        assert_eq!(groups[0].len(), 5);
1804        assert!(groups[0].contains(&circle));
1805    }
1806
1807    #[test]
1808    fn geometric_components_merges_crossing_rects() {
1809        let mut data = SketchData::default();
1810        data.add_rect(DVec2::ZERO, DVec2::new(2.0, 2.0));
1811        data.add_rect(DVec2::new(1.0, 1.0), DVec2::new(3.0, 3.0));
1812        let groups = data.geometric_components(32);
1813        assert_eq!(groups.len(), 1);
1814        assert_eq!(groups[0].len(), 8);
1815    }
1816
1817    #[test]
1818    fn geometric_components_three_level_nesting_single_group() {
1819        let mut data = SketchData::default();
1820        data.add_rect(DVec2::ZERO, DVec2::new(8.0, 8.0));
1821        data.add_rect(DVec2::new(1.0, 1.0), DVec2::new(7.0, 7.0));
1822        data.add_rect(DVec2::new(2.0, 2.0), DVec2::new(6.0, 6.0));
1823        let groups = data.geometric_components(32);
1824        assert_eq!(groups.len(), 1, "nesting must merge transitively");
1825        assert_eq!(groups[0].len(), 12);
1826    }
1827
1828    #[test]
1829    fn geometric_components_nested_group_plus_far_shape() {
1830        let mut data = rect_sketch();
1831        let inner = data.add_circle(DVec2::new(1.0, 0.5), 0.25);
1832        let far = data.add_circle(DVec2::new(20.0, 20.0), 1.0);
1833        let groups = data.geometric_components(32);
1834        assert_eq!(groups.len(), 2);
1835        assert_eq!(groups[0].len(), 5, "rect + inner circle merge");
1836        assert!(groups[0].contains(&inner));
1837        assert_eq!(groups[1], vec![far]);
1838    }
1839
1840    #[test]
1841    fn geometric_components_open_chain_never_merges() {
1842        let mut data = SketchData::default();
1843        data.add_rect(DVec2::ZERO, DVec2::new(4.0, 4.0));
1844        // Open chain fully inside the rect: no loops, so it never merges.
1845        let a = data.add_point(DVec2::new(1.0, 1.0));
1846        let b = data.add_point(DVec2::new(2.0, 1.0));
1847        let c = data.add_point(DVec2::new(2.0, 2.0));
1848        let la = data.add_line(a, b);
1849        let lb = data.add_line(b, c);
1850        let groups = data.geometric_components(32);
1851        assert_eq!(groups.len(), 2);
1852        assert_eq!(groups[1], vec![la, lb]);
1853    }
1854
1855    #[test]
1856    fn geometric_components_identical_duplicate_shapes() {
1857        let mut data = SketchData::default();
1858        data.add_rect(DVec2::ZERO, DVec2::new(2.0, 1.0));
1859        data.add_rect(DVec2::ZERO, DVec2::new(2.0, 1.0));
1860        // Mutual half-open containment: merged, deterministic, no panic.
1861        let groups = data.geometric_components(32);
1862        assert_eq!(groups.len(), 1);
1863        assert_eq!(groups[0].len(), 8);
1864        // Degenerate but stable: each loop nests in its twin, so both flip CW.
1865        let profile = profile_loops(&data, 32);
1866        assert_eq!(profile.loops.len(), 2);
1867        for l in &profile.loops {
1868            assert!(signed_area_2x(l) < 0.0);
1869        }
1870    }
1871
1872    #[cfg(feature = "serde")]
1873    #[test]
1874    fn sketch_data_roundtrips_ron() {
1875        let mut data = rect_sketch();
1876        let circle = data.add_circle(DVec2::new(1.0, 0.5), 0.25);
1877        data.set_construction(circle, true);
1878        data.constraints.push(SketchConstraint::Distance {
1879            a: data.points[0].id,
1880            b: data.points[1].id,
1881            d: 2.0,
1882        });
1883        let ron = ron::to_string(&data).expect("serialize");
1884        let back: SketchData = ron::from_str(&ron).expect("deserialize");
1885        assert_eq!(data, back);
1886    }
1887
1888    #[cfg(feature = "serde")]
1889    #[test]
1890    fn construction_defaults_false_on_old_files() {
1891        // A pre-construction-field file: entities without the flag.
1892        let ron = "(points:[(id:0,pos:(0.0,0.0)),(id:1,pos:(2.0,0.0))],\
1893                   entities:[(id:2,kind:Line(p0:0,p1:1))],constraints:[],next_id:3)";
1894        let data: SketchData = ron::from_str(ron).expect("deserialize old file");
1895        assert!(!data.entities[0].construction);
1896    }
1897
1898    // ── Construction geometry ──────────────────────────────
1899
1900    #[test]
1901    fn construction_entities_excluded_from_profile() {
1902        let mut data = rect_sketch();
1903        // Construction diagonal between two rect corners: no open chain, no
1904        // effect on the loop.
1905        let diag = data.add_line(data.points[0].id, data.points[2].id);
1906        data.set_construction(diag, true);
1907        let profile = profile_loops(&data, 32);
1908        assert_eq!(profile.loops.len(), 1);
1909        assert_eq!(profile.open_chains, 0);
1910
1911        // Construction circle inside the rect: no hole.
1912        let circle = data.add_circle(DVec2::new(1.0, 0.5), 0.25);
1913        data.set_construction(circle, true);
1914        let profile = profile_loops(&data, 32);
1915        assert_eq!(profile.loops.len(), 1);
1916    }
1917
1918    #[test]
1919    fn geometric_components_ignore_construction_circle() {
1920        let mut data = rect_sketch();
1921        let circle = data.add_circle(DVec2::new(1.0, 0.5), 0.25);
1922        data.set_construction(circle, true);
1923        // Unlike geometric_components_merges_nested_circle_into_rect: the
1924        // construction circle produces no loop, so nothing merges.
1925        let groups = data.geometric_components(32);
1926        assert_eq!(groups.len(), 2);
1927        assert_eq!(groups[1], vec![circle]);
1928    }
1929
1930    // ── Intersection math ──────────────────────────────────
1931
1932    #[test]
1933    fn segment_segment_intersection_point_and_policy() {
1934        let p = DVec2::new;
1935        let x = segment_segment_intersection(p(0.0, 0.0), p(2.0, 2.0), p(0.0, 2.0), p(2.0, 0.0))
1936            .expect("X-cross");
1937        assert!((x - p(1.0, 1.0)).length() < 1e-12);
1938        // Shared endpoint, T-junction, collinear, parallel: all None.
1939        assert!(segment_segment_intersection(p(0., 0.), p(2., 0.), p(2., 0.), p(3., 1.)).is_none());
1940        assert!(segment_segment_intersection(p(0., 0.), p(2., 0.), p(1., 0.), p(1., 5.)).is_none());
1941        assert!(segment_segment_intersection(p(0., 0.), p(2., 0.), p(1., 0.), p(3., 0.)).is_none());
1942        assert!(segment_segment_intersection(p(0., 0.), p(2., 0.), p(0., 1.), p(2., 1.)).is_none());
1943    }
1944
1945    #[test]
1946    fn segment_circle_intersections_secant_tangent_miss() {
1947        let p = DVec2::new;
1948        let c = p(0.0, 0.0);
1949        // Secant through the center: two roots ordered by t.
1950        let hits = segment_circle_intersections(p(-2.0, 0.0), p(2.0, 0.0), c, 1.0);
1951        assert_eq!(hits.len(), 2);
1952        assert!((hits[0] - p(-1.0, 0.0)).length() < 1e-9);
1953        assert!((hits[1] - p(1.0, 0.0)).length() < 1e-9);
1954        // Tangent graze: nothing.
1955        assert!(segment_circle_intersections(p(-2.0, 1.0), p(2.0, 1.0), c, 1.0).is_empty());
1956        // Miss: nothing.
1957        assert!(segment_circle_intersections(p(-2.0, 3.0), p(2.0, 3.0), c, 1.0).is_empty());
1958        // Segment ends inside the circle: only the entry root.
1959        let hits = segment_circle_intersections(p(-2.0, 0.0), p(0.0, 0.0), c, 1.0);
1960        assert_eq!(hits.len(), 1);
1961        assert!((hits[0] - p(-1.0, 0.0)).length() < 1e-9);
1962    }
1963
1964    #[test]
1965    fn segment_arc_intersections_respect_sweep() {
1966        let p = DVec2::new;
1967        // Upper half-circle r=1 (CCW from (1,0) to (-1,0)).
1968        let (c, s, e) = (p(0.0, 0.0), p(1.0, 0.0), p(-1.0, 0.0));
1969        // Vertical chord at x=0 crosses the full circle twice, the upper arc once.
1970        let hits = segment_arc_intersections(p(0.0, -2.0), p(0.0, 2.0), c, s, e);
1971        assert_eq!(hits.len(), 1);
1972        assert!((hits[0] - p(0.0, 1.0)).length() < 1e-9);
1973        // A chord entirely under the arc's span: nothing.
1974        assert!(segment_arc_intersections(p(-2.0, -0.5), p(2.0, -0.5), c, s, e).is_empty());
1975    }
1976
1977    #[test]
1978    fn nearest_intersection_picks_closest_within_tolerance() {
1979        let mut data = SketchData::default();
1980        let a0 = data.add_point(DVec2::new(-1.0, 0.0));
1981        let a1 = data.add_point(DVec2::new(1.0, 0.0));
1982        let h = data.add_line(a0, a1);
1983        let b0 = data.add_point(DVec2::new(-0.5, -1.0));
1984        let b1 = data.add_point(DVec2::new(-0.5, 1.0));
1985        let v1 = data.add_line(b0, b1);
1986        let c0 = data.add_point(DVec2::new(0.5, -1.0));
1987        let c1 = data.add_point(DVec2::new(0.5, 1.0));
1988        data.add_line(c0, c1);
1989
1990        let hit = data
1991            .nearest_intersection(DVec2::new(-0.4, 0.1), 0.5)
1992            .expect("hit");
1993        assert_eq!((hit.a, hit.b), (h, v1));
1994        assert!((hit.pos - DVec2::new(-0.5, 0.0)).length() < 1e-12);
1995        // Beyond tolerance: nothing.
1996        assert!(
1997            data.nearest_intersection(DVec2::new(5.0, 5.0), 0.5)
1998                .is_none()
1999        );
2000
2001        // Unsupported circle×circle pair: ignored.
2002        let mut data = SketchData::default();
2003        data.add_circle(DVec2::new(-0.5, 0.0), 1.0);
2004        data.add_circle(DVec2::new(0.5, 0.0), 1.0);
2005        assert!(
2006            data.nearest_intersection(DVec2::new(0.0, 0.8), 10.0)
2007                .is_none()
2008        );
2009    }
2010
2011    // ── Splitting ──────────────────────────────────────────
2012
2013    #[test]
2014    fn split_line_line_shares_one_point() {
2015        let mut data = SketchData::default();
2016        let a0 = data.add_point(DVec2::new(-1.0, 0.0));
2017        let a1 = data.add_point(DVec2::new(1.0, 0.0));
2018        let la = data.add_line(a0, a1);
2019        let b0 = data.add_point(DVec2::new(0.0, -1.0));
2020        let b1 = data.add_point(DVec2::new(0.0, 1.0));
2021        let lb = data.add_line(b0, b1);
2022        data.constraints
2023            .push(SketchConstraint::Horizontal { line: la });
2024        let n_points = data.points.len();
2025
2026        let new_ids = data
2027            .split_at(la, lb, DVec2::new(0.05, 0.05), 1e-6)
2028            .expect("split");
2029        assert_eq!(new_ids.len(), 2);
2030        assert_eq!(data.entities.len(), 4);
2031        assert_eq!(data.points.len(), n_points + 1, "one shared point");
2032        let x = data.points.last().expect("split point");
2033        assert!((DVec2::from_array(x.pos) - DVec2::ZERO).length() < 1e-12);
2034        // All four fragments reference the shared point.
2035        let uses_x = data
2036            .entities
2037            .iter()
2038            .filter(|e| entity_point_ids(e).contains(&x.id))
2039            .count();
2040        assert_eq!(uses_x, 4);
2041        // Old ids kept by the first fragments.
2042        assert!(data.entity(la).is_some());
2043        assert!(data.entity(lb).is_some());
2044        // Horizontal duplicated onto la's new fragment.
2045        let horizontals = data
2046            .constraints
2047            .iter()
2048            .filter(|c| matches!(c, SketchConstraint::Horizontal { .. }))
2049            .count();
2050        assert_eq!(horizontals, 2);
2051    }
2052
2053    #[test]
2054    fn split_line_arc_preserves_winding() {
2055        let mut data = SketchData::default();
2056        // Upper half-circle from (1,0) to (-1,0), r=1.
2057        let c = data.add_point(DVec2::ZERO);
2058        let s = data.add_point(DVec2::new(1.0, 0.0));
2059        let e = data.add_point(DVec2::new(-1.0, 0.0));
2060        let arc = data.add_arc(c, s, e);
2061        let l0 = data.add_point(DVec2::new(0.0, -2.0));
2062        let l1 = data.add_point(DVec2::new(0.0, 2.0));
2063        let line = data.add_line(l0, l1);
2064
2065        let new_ids = data
2066            .split_at(line, arc, DVec2::new(0.0, 1.0), 1e-6)
2067            .expect("split");
2068        assert_eq!(new_ids.len(), 2);
2069        assert_eq!(data.entities.len(), 4);
2070        let sweeps: Vec<f64> = data
2071            .entities
2072            .iter()
2073            .filter_map(|ent| match ent.kind {
2074                SketchEntityKind::Arc { center, start, end } => Some(arc_sweep_angle(
2075                    data.point_pos(center).unwrap(),
2076                    data.point_pos(start).unwrap(),
2077                    data.point_pos(end).unwrap(),
2078                )),
2079                _ => None,
2080            })
2081            .collect();
2082        assert_eq!(sweeps.len(), 2);
2083        let total: f64 = sweeps.iter().sum();
2084        assert!(
2085            (total - core::f64::consts::PI).abs() < 1e-9,
2086            "fragment sweeps must sum to the original half-turn: {sweeps:?}"
2087        );
2088    }
2089
2090    #[test]
2091    fn split_line_circle_makes_two_arcs() {
2092        let mut data = SketchData::default();
2093        let circle = data.add_circle(DVec2::ZERO, 1.0);
2094        let l0 = data.add_point(DVec2::new(-2.0, 0.0));
2095        let l1 = data.add_point(DVec2::new(2.0, 0.0));
2096        let line = data.add_line(l0, l1);
2097        data.constraints.push(SketchConstraint::Radius {
2098            entity: circle,
2099            r: 1.0,
2100        });
2101        let n_points = data.points.len();
2102
2103        // Click near the right crossing (1, 0).
2104        let new_ids = data
2105            .split_at(line, circle, DVec2::new(0.9, 0.1), 1e-6)
2106            .expect("split");
2107        assert_eq!(new_ids.len(), 2);
2108        // Circle became two arcs; line split at the clicked root only.
2109        let arcs = data
2110            .entities
2111            .iter()
2112            .filter(|e| matches!(e.kind, SketchEntityKind::Arc { .. }))
2113            .count();
2114        let lines = data
2115            .entities
2116            .iter()
2117            .filter(|e| matches!(e.kind, SketchEntityKind::Line { .. }))
2118            .count();
2119        assert_eq!((arcs, lines), (2, 2));
2120        // Shared point at (1,0) + circle-only point at (-1,0).
2121        assert_eq!(data.points.len(), n_points + 2);
2122        // Radius duplicated onto both arcs.
2123        let radii = data
2124            .constraints
2125            .iter()
2126            .filter(|c| matches!(c, SketchConstraint::Radius { .. }))
2127            .count();
2128        assert_eq!(radii, 2);
2129        // Both fragments still cover the full circle.
2130        let total: f64 = data
2131            .entities
2132            .iter()
2133            .filter_map(|ent| match ent.kind {
2134                SketchEntityKind::Arc { center, start, end } => Some(arc_sweep_angle(
2135                    data.point_pos(center).unwrap(),
2136                    data.point_pos(start).unwrap(),
2137                    data.point_pos(end).unwrap(),
2138                )),
2139                _ => None,
2140            })
2141            .sum();
2142        assert!((total - core::f64::consts::TAU).abs() < 1e-9);
2143    }
2144
2145    #[test]
2146    fn split_line_ending_inside_circle_splits_line_only() {
2147        let mut data = SketchData::default();
2148        let circle = data.add_circle(DVec2::ZERO, 1.0);
2149        let l0 = data.add_point(DVec2::new(-2.0, 0.0));
2150        let l1 = data.add_point(DVec2::ZERO); // ends at the center
2151        let line = data.add_line(l0, l1);
2152
2153        let new_ids = data
2154            .split_at(line, circle, DVec2::new(-1.0, 0.0), 1e-6)
2155            .expect("split");
2156        assert_eq!(new_ids.len(), 1, "only the line splits");
2157        assert!(
2158            matches!(
2159                data.entity(circle).expect("circle").kind,
2160                SketchEntityKind::Circle { .. }
2161            ),
2162            "circle stays whole without a second crossing"
2163        );
2164    }
2165
2166    #[test]
2167    fn split_skips_near_endpoints() {
2168        let mut data = SketchData::default();
2169        // Vertical line crossing a horizontal one right next to its start.
2170        let a0 = data.add_point(DVec2::new(0.0, 0.0));
2171        let a1 = data.add_point(DVec2::new(2.0, 0.0));
2172        let la = data.add_line(a0, a1);
2173        let b0 = data.add_point(DVec2::new(0.01, -1.0));
2174        let b1 = data.add_point(DVec2::new(0.01, 1.0));
2175        let lb = data.add_line(b0, b1);
2176
2177        // Tolerance bigger than the 0.01 offset: la's fragment would be
2178        // degenerate, so only lb splits.
2179        let new_ids = data
2180            .split_at(la, lb, DVec2::new(0.01, 0.0), 0.05)
2181            .expect("split");
2182        assert_eq!(new_ids.len(), 1);
2183        assert_eq!(data.entities.len(), 3);
2184
2185        // Both sides degenerate: Err, data untouched.
2186        let mut data = SketchData::default();
2187        let a0 = data.add_point(DVec2::new(0.0, 0.0));
2188        let a1 = data.add_point(DVec2::new(2.0, 0.0));
2189        let la = data.add_line(a0, a1);
2190        let b0 = data.add_point(DVec2::new(0.01, -0.02));
2191        let b1 = data.add_point(DVec2::new(0.01, 0.02));
2192        let lb = data.add_line(b0, b1);
2193        let before = data.clone();
2194        assert!(data.split_at(la, lb, DVec2::new(0.01, 0.0), 0.05).is_err());
2195        assert_eq!(data, before);
2196    }
2197
2198    #[test]
2199    fn split_unsupported_pair_and_no_intersection_err() {
2200        let mut data = SketchData::default();
2201        let c1 = data.add_circle(DVec2::ZERO, 1.0);
2202        let c2 = data.add_circle(DVec2::new(1.0, 0.0), 1.0);
2203        assert_eq!(
2204            data.split_at(c1, c2, DVec2::ZERO, 1e-6),
2205            Err("unsupported entity pair")
2206        );
2207        let l0 = data.add_point(DVec2::new(5.0, 5.0));
2208        let l1 = data.add_point(DVec2::new(6.0, 5.0));
2209        let line = data.add_line(l0, l1);
2210        assert_eq!(
2211            data.split_at(line, c1, DVec2::ZERO, 1e-6),
2212            Err("no intersection")
2213        );
2214    }
2215
2216    #[test]
2217    fn split_retargets_tangent_and_point_on_curve() {
2218        // Tangent{line}: line tangent to a circle sitting under its left half.
2219        let mut data = SketchData::default();
2220        let l0 = data.add_point(DVec2::new(-4.0, 1.0));
2221        let l1 = data.add_point(DVec2::new(4.0, 1.0));
2222        let line = data.add_line(l0, l1);
2223        let circle = data.add_circle(DVec2::new(-3.0, 0.0), 1.0);
2224        data.constraints.push(SketchConstraint::Tangent {
2225            line,
2226            entity: circle,
2227        });
2228        // Cross with a vertical line at x=2 and split there: the tangency
2229        // (foot at x=-3) stays with the LEFT fragment = old id.
2230        let v0 = data.add_point(DVec2::new(2.0, -1.0));
2231        let v1 = data.add_point(DVec2::new(2.0, 3.0));
2232        let vline = data.add_line(v0, v1);
2233        data.split_at(line, vline, DVec2::new(2.0, 1.0), 1e-6)
2234            .expect("split");
2235        assert!(
2236            data.constraints
2237                .iter()
2238                .any(|c| matches!(c, SketchConstraint::Tangent { line: l, .. } if *l == line)),
2239            "tangent stays on the fragment containing the foot"
2240        );
2241
2242        // PointOnCurve{entity}: point on the left of a circle, split at the
2243        // right crossing → constraint follows the left arc fragment.
2244        let mut data = SketchData::default();
2245        let circle = data.add_circle(DVec2::ZERO, 1.0);
2246        let p = data.add_point(DVec2::new(-1.0, 0.0));
2247        data.constraints.push(SketchConstraint::PointOnCurve {
2248            point: p,
2249            entity: circle,
2250        });
2251        let l0 = data.add_point(DVec2::new(0.5, -2.0));
2252        let l1 = data.add_point(DVec2::new(0.5, 2.0));
2253        let line = data.add_line(l0, l1);
2254        data.split_at(line, circle, DVec2::new(0.5, 0.9), 1e-6)
2255            .expect("split");
2256        let target = data
2257            .constraints
2258            .iter()
2259            .find_map(|c| match c {
2260                SketchConstraint::PointOnCurve { entity, .. } => Some(*entity),
2261                _ => None,
2262            })
2263            .expect("constraint kept");
2264        let d_target = data.entity_distance_to(target, DVec2::new(-1.0, 0.0));
2265        assert!(
2266            d_target < 1e-6,
2267            "PointOnCurve must follow the fragment containing the point: {d_target}"
2268        );
2269    }
2270
2271    #[test]
2272    fn split_inherits_construction_flag() {
2273        let mut data = SketchData::default();
2274        let a0 = data.add_point(DVec2::new(-1.0, 0.0));
2275        let a1 = data.add_point(DVec2::new(1.0, 0.0));
2276        let la = data.add_line(a0, a1);
2277        data.set_construction(la, true);
2278        let b0 = data.add_point(DVec2::new(0.0, -1.0));
2279        let b1 = data.add_point(DVec2::new(0.0, 1.0));
2280        let lb = data.add_line(b0, b1);
2281
2282        let new_ids = data.split_at(la, lb, DVec2::ZERO, 1e-6).expect("split");
2283        let frag_of = |old: SketchId| {
2284            data.entities.iter().find(|e| {
2285                new_ids.contains(&e.id) && e.construction == data.entity(old).unwrap().construction
2286            })
2287        };
2288        assert!(data.entity(la).expect("la").construction);
2289        assert!(frag_of(la).expect("la fragment").construction);
2290        assert!(!data.entity(lb).expect("lb").construction);
2291    }
2292}