Skip to main content

geop_core_sketch/
sketch.rs

1//! Sketch entities and constraints: plain `f64` design data.
2//!
3//! Points are the only entities with positions of their own; lines, arcs and
4//! splines reference points by [`PointId`], so two curves that share an
5//! endpoint share the *same* point and stay connected by construction.
6//! Separately drawn points are joined with [`Constraint::Coincident`], which
7//! the solver treats the same way (the two points become one set of
8//! variables), so connectivity is always structural — never inferred from
9//! two positions happening to be close.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use geop_core_math::geop_error::{GeopError, GeopResult};
14use serde::{Deserialize, Serialize};
15
16macro_rules! define_ids {
17    ($($(#[$doc:meta])* $name:ident => $prefix:literal),* $(,)?) => {
18        $(
19            $(#[$doc])*
20            #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
21            #[serde(transparent)]
22            pub struct $name(pub u64);
23
24            /// The id as it appears in a topological name, e.g. `p3`.
25            impl std::fmt::Display for $name {
26                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27                    write!(f, concat!($prefix, "{}"), self.0)
28                }
29            }
30
31            impl<'de> Deserialize<'de> for $name {
32                fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
33                    d.deserialize_any(IdVisitor).map($name)
34                }
35            }
36        )*
37    };
38}
39
40/// Reads an id from a number, or from the string a JSON object key holds it
41/// as. Serde's derived `u64` accepts the key string only when it knows the
42/// target type up front, not when the map was first buffered — as it is
43/// inside a `#[serde(flatten)]`ed or internally tagged container, where a
44/// sketch naturally ends up.
45struct IdVisitor;
46
47impl serde::de::Visitor<'_> for IdVisitor {
48    type Value = u64;
49
50    fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
51        f.write_str("a non-negative integer id, as a number or a string")
52    }
53
54    fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<u64, E> {
55        Ok(v)
56    }
57
58    fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<u64, E> {
59        u64::try_from(v).map_err(|_| E::custom(format!("id {v} is negative")))
60    }
61
62    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<u64, E> {
63        v.parse()
64            .map_err(|_| E::custom(format!("{v:?} is not an integer id")))
65    }
66}
67
68define_ids!(
69    /// Key of [`Sketch::points`].
70    PointId => "p",
71    /// Key of [`Sketch::curves`].
72    CurveId => "c",
73    /// Key of [`Sketch::constraints`].
74    ConstraintId => "k",
75);
76
77#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
78pub struct Point {
79    pub x: f64,
80    pub y: f64,
81}
82
83impl Point {
84    pub fn xy(&self) -> [f64; 2] {
85        [self.x, self.y]
86    }
87}
88
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "type")]
91pub enum CurveKind {
92    Line {
93        start: PointId,
94        end: PointId,
95    },
96    /// A circular arc from `start` to `end`, turning counter-clockwise by
97    /// `sweep` radians (clockwise if negative, `|sweep| < 2π`). Equivalent to
98    /// giving its curvature `2 sin(sweep / 2) / |end - start|` — see
99    /// [`crate::geometry`] for why the sweep is what is stored and solved.
100    Arc {
101        start: PointId,
102        end: PointId,
103        sweep: f64,
104    },
105    Circle {
106        center: PointId,
107        radius: f64,
108    },
109    /// A clamped, uniform, non-rational B-spline of degree
110    /// `min(3, control_points.len() - 1)` through its first and last control
111    /// points.
112    Spline {
113        control_points: Vec<PointId>,
114    },
115}
116
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118pub struct Curve {
119    #[serde(flatten)]
120    pub kind: CurveKind,
121    /// Construction geometry takes part in constraints but not in profiles
122    /// (e.g. a revolve axis or a symmetry line).
123    #[serde(default)]
124    pub construction: bool,
125}
126
127impl Curve {
128    /// The points this curve is defined by.
129    pub fn points(&self) -> Vec<PointId> {
130        match &self.kind {
131            CurveKind::Line { start, end } | CurveKind::Arc { start, end, .. } => {
132                vec![*start, *end]
133            }
134            CurveKind::Circle { center, .. } => vec![*center],
135            CurveKind::Spline { control_points } => control_points.clone(),
136        }
137    }
138
139    /// `(start, end)` for an open curve, `None` for a circle.
140    pub fn endpoints(&self) -> Option<(PointId, PointId)> {
141        match &self.kind {
142            CurveKind::Line { start, end } | CurveKind::Arc { start, end, .. } => {
143                Some((*start, *end))
144            }
145            CurveKind::Circle { .. } => None,
146            CurveKind::Spline { control_points } => {
147                Some((control_points[0], *control_points.last()?))
148            }
149        }
150    }
151}
152
153/// The typical CAD sketch constraints. Distances and lengths are in sketch
154/// units, angles in radians.
155#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
156#[serde(tag = "type")]
157pub enum Constraint {
158    /// `a` and `b` are the same point.
159    Coincident {
160        a: PointId,
161        b: PointId,
162    },
163    /// `point` lies on `curve` (a line's infinite extension, an arc's full
164    /// circle, or a circle).
165    PointOnCurve {
166        point: PointId,
167        curve: CurveId,
168    },
169    Horizontal {
170        line: CurveId,
171    },
172    Vertical {
173        line: CurveId,
174    },
175    Parallel {
176        a: CurveId,
177        b: CurveId,
178    },
179    Perpendicular {
180        a: CurveId,
181        b: CurveId,
182    },
183    /// Two lines on one infinite line.
184    Collinear {
185        a: CurveId,
186        b: CurveId,
187    },
188    /// Two curves meet tangentially: at a shared endpoint if they have one
189    /// (lines, arcs and splines), else a line touching a circle/arc, or two
190    /// circles/arcs touching each other.
191    Tangent {
192        a: CurveId,
193        b: CurveId,
194    },
195    /// Equal length (two lines) or equal radius (two circles/arcs).
196    Equal {
197        a: CurveId,
198        b: CurveId,
199    },
200    /// Two circles/arcs share a center.
201    Concentric {
202        a: CurveId,
203        b: CurveId,
204    },
205    /// `point` is the midpoint of a line or arc.
206    Midpoint {
207        point: PointId,
208        curve: CurveId,
209    },
210    /// `a` and `b` are mirror images across `line`.
211    Symmetric {
212        a: PointId,
213        b: PointId,
214        line: CurveId,
215    },
216    /// `point` stays at `(x, y)`.
217    Fix {
218        point: PointId,
219        x: f64,
220        y: f64,
221    },
222    Distance {
223        a: PointId,
224        b: PointId,
225        value: f64,
226    },
227    /// `b.x - a.x = value`.
228    DistanceX {
229        a: PointId,
230        b: PointId,
231        value: f64,
232    },
233    /// `b.y - a.y = value`.
234    DistanceY {
235        a: PointId,
236        b: PointId,
237        value: f64,
238    },
239    PointLineDistance {
240        point: PointId,
241        line: CurveId,
242        value: f64,
243    },
244    /// Length of a line or arc.
245    Length {
246        curve: CurveId,
247        value: f64,
248    },
249    /// Radius of a circle or arc.
250    Radius {
251        curve: CurveId,
252        value: f64,
253    },
254    /// The counter-clockwise angle from line `a`'s direction to line `b`'s.
255    Angle {
256        a: CurveId,
257        b: CurveId,
258        value: f64,
259    },
260}
261
262impl Constraint {
263    /// The points this constraint refers to directly (curves aside).
264    pub fn points(&self) -> Vec<PointId> {
265        use Constraint::*;
266        match *self {
267            Coincident { a, b }
268            | Distance { a, b, .. }
269            | DistanceX { a, b, .. }
270            | DistanceY { a, b, .. }
271            | Symmetric { a, b, .. } => vec![a, b],
272            PointOnCurve { point, .. }
273            | Midpoint { point, .. }
274            | Fix { point, .. }
275            | PointLineDistance { point, .. } => vec![point],
276            _ => Vec::new(),
277        }
278    }
279}
280
281/// Every point's `[x, y]`, by [`PointId`]: the sketch's own positions
282/// ([`Sketch::positions`]) or a rigid motion of them (see
283/// [`crate::ProfileLoop::to_nurbs`]).
284pub type Positions = BTreeMap<PointId, [f64; 2]>;
285
286/// A constraint sketch.
287///
288/// Every entity is keyed by a stable id rather than stored by position: an id
289/// is handed out once, by the `add_*` methods, and never reused — not even
290/// after the entity is removed. That is what lets anything outside the sketch
291/// (a constraint, a profile, a topological name of the solid extruded from
292/// it) keep referring to "that line" while the sketch is edited around it.
293/// The maps are ordered, so a serialized sketch is deterministic and diffs
294/// line by line.
295#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
296pub struct Sketch {
297    pub points: BTreeMap<PointId, Point>,
298    pub curves: BTreeMap<CurveId, Curve>,
299    pub constraints: BTreeMap<ConstraintId, Constraint>,
300    /// The id the next added entity gets; greater than every id in use. One
301    /// counter for all three kinds, so an id `add_*` hands out is unique
302    /// across the sketch.
303    pub next_id: u64,
304}
305
306impl Sketch {
307    pub fn new() -> Self {
308        Self::default()
309    }
310
311    pub fn point(&self, id: PointId) -> GeopResult<&Point> {
312        self.points
313            .get(&id)
314            .ok_or_else(|| GeopError::new(format!("sketch has no point {id}")))
315    }
316
317    pub fn curve(&self, id: CurveId) -> GeopResult<&Curve> {
318        self.curves
319            .get(&id)
320            .ok_or_else(|| GeopError::new(format!("sketch has no curve {id}")))
321    }
322
323    /// Every point's `[x, y]`.
324    pub fn positions(&self) -> Positions {
325        self.points.iter().map(|(&id, p)| (id, p.xy())).collect()
326    }
327
328    fn fresh_id(&mut self) -> u64 {
329        let id = self.next_id;
330        self.next_id += 1;
331        id
332    }
333
334    /// Takes the id `id` for a new entity: `taken` says whether an entity of
335    /// its kind already has it. Ids only need to be unique per kind — `p0`
336    /// and `c0` are never confused — but the counter stays above all of
337    /// them.
338    fn claim_id(&mut self, id: u64, taken: bool) -> GeopResult<()> {
339        if taken {
340            return Err(GeopError::new(format!("sketch id {id} is already in use")));
341        }
342        self.next_id = self.next_id.max(id + 1);
343        Ok(())
344    }
345
346    /// Adds `point` under the id `id` rather than a fresh one — for reading
347    /// back a sketch whose ids were chosen already, so that what refers to
348    /// them keeps doing so. Fails if `id` is taken.
349    pub fn insert_point(&mut self, id: PointId, point: Point) -> GeopResult<()> {
350        self.claim_id(id.0, self.points.contains_key(&id))?;
351        self.points.insert(id, point);
352        Ok(())
353    }
354
355    /// Like [`Sketch::insert_point`], for a curve.
356    pub fn insert_curve(&mut self, id: CurveId, curve: Curve) -> GeopResult<()> {
357        self.claim_id(id.0, self.curves.contains_key(&id))?;
358        self.curves.insert(id, curve);
359        Ok(())
360    }
361
362    pub fn add_point(&mut self, x: f64, y: f64) -> PointId {
363        let id = PointId(self.fresh_id());
364        self.points.insert(id, Point { x, y });
365        id
366    }
367
368    fn add_curve(&mut self, kind: CurveKind) -> CurveId {
369        let id = CurveId(self.fresh_id());
370        self.curves.insert(
371            id,
372            Curve {
373                kind,
374                construction: false,
375            },
376        );
377        id
378    }
379
380    pub fn add_line(&mut self, start: PointId, end: PointId) -> CurveId {
381        self.add_curve(CurveKind::Line { start, end })
382    }
383
384    /// An arc from `start` to `end` with signed `curvature` (positive turns
385    /// counter-clockwise): the minor arc, or a half circle if `|curvature|`
386    /// exceeds what the chord allows. For a major arc, give the sweep
387    /// directly via [`Sketch::add_arc_with_sweep`].
388    pub fn add_arc(&mut self, start: PointId, end: PointId, curvature: f64) -> CurveId {
389        let [sx, sy] = self.points[&start].xy();
390        let [ex, ey] = self.points[&end].xy();
391        let chord = (ex - sx).hypot(ey - sy);
392        let sweep = 2.0 * (curvature * chord / 2.0).clamp(-1.0, 1.0).asin();
393        self.add_arc_with_sweep(start, end, sweep)
394    }
395
396    pub fn add_arc_with_sweep(&mut self, start: PointId, end: PointId, sweep: f64) -> CurveId {
397        self.add_curve(CurveKind::Arc { start, end, sweep })
398    }
399
400    pub fn add_circle(&mut self, center: PointId, radius: f64) -> CurveId {
401        self.add_curve(CurveKind::Circle { center, radius })
402    }
403
404    pub fn add_spline(&mut self, control_points: Vec<PointId>) -> CurveId {
405        self.add_curve(CurveKind::Spline { control_points })
406    }
407
408    pub fn set_construction(&mut self, curve: CurveId, construction: bool) {
409        if let Some(c) = self.curves.get_mut(&curve) {
410            c.construction = construction;
411        }
412    }
413
414    pub fn constrain(&mut self, constraint: Constraint) -> ConstraintId {
415        let id = ConstraintId(self.fresh_id());
416        self.constraints.insert(id, constraint);
417        id
418    }
419
420    /// Check every reference and every constraint's operand kinds, so the
421    /// solver and profile code can rely on them.
422    pub fn validate(&self) -> GeopResult<()> {
423        let max_id = [
424            self.points.keys().last().map(|id| id.0),
425            self.curves.keys().last().map(|id| id.0),
426            self.constraints.keys().last().map(|id| id.0),
427        ];
428        if let Some(max_id) = max_id.into_iter().flatten().max()
429            && max_id >= self.next_id
430        {
431            return Err(GeopError::new(format!(
432                "sketch uses id {max_id}, but its next_id is only {}: new entities would reuse ids",
433                self.next_id
434            )));
435        }
436        for (&i, curve) in &self.curves {
437            for p in curve.points() {
438                self.point(p)
439                    .map_err(|e| e.with_context(format!("curve {i}")))?;
440            }
441            match &curve.kind {
442                CurveKind::Line { start, end } | CurveKind::Arc { start, end, .. }
443                    if start == end =>
444                {
445                    return Err(GeopError::new(format!(
446                        "curve {i} starts and ends at the same point"
447                    )));
448                }
449                CurveKind::Arc { sweep, .. }
450                    if sweep.is_nan() || sweep.abs() >= std::f64::consts::TAU =>
451                {
452                    return Err(GeopError::new(format!(
453                        "arc {i} has sweep {sweep}, which is not within (-2π, 2π)"
454                    )));
455                }
456                CurveKind::Spline { control_points } if control_points.len() < 2 => {
457                    return Err(GeopError::new(format!(
458                        "spline {i} needs at least 2 control points"
459                    )));
460                }
461                _ => {}
462            }
463        }
464        for (&i, c) in &self.constraints {
465            self.validate_constraint(c)
466                .map_err(|e| e.with_context(format!("constraint {i} = {c:?}")))?;
467        }
468        Ok(())
469    }
470
471    fn validate_constraint(&self, c: &Constraint) -> GeopResult<()> {
472        use Constraint::*;
473        for p in c.points() {
474            self.point(p)?;
475        }
476        let kind = |id: CurveId| self.curve(id).map(|c| &c.kind);
477        let is_line = |id| Ok::<_, GeopError>(matches!(kind(id)?, CurveKind::Line { .. }));
478        let is_round = |id| {
479            Ok::<_, GeopError>(matches!(
480                kind(id)?,
481                CurveKind::Arc { .. } | CurveKind::Circle { .. }
482            ))
483        };
484        let need = |ok: bool, what: &str| {
485            if ok {
486                Ok(())
487            } else {
488                Err(GeopError::new(format!("operands must be {what}")))
489            }
490        };
491        match c {
492            Coincident { .. }
493            | Distance { .. }
494            | DistanceX { .. }
495            | DistanceY { .. }
496            | Fix { .. } => Ok(()),
497            PointOnCurve { curve, .. } => need(
498                !matches!(kind(*curve)?, CurveKind::Spline { .. }),
499                "a point and a line, arc or circle",
500            ),
501            Horizontal { line } | Vertical { line } => need(is_line(*line)?, "a line"),
502            Parallel { a, b }
503            | Perpendicular { a, b }
504            | Collinear { a, b }
505            | Angle { a, b, .. } => need(is_line(*a)? && is_line(*b)?, "two lines"),
506            Tangent { a, b } => {
507                let shared = self.shared_endpoint(*a, *b)?.is_some();
508                let ok = shared
509                    && !(is_line(*a)? && is_line(*b)?)
510                    && !matches!(kind(*a)?, CurveKind::Circle { .. })
511                    && !matches!(kind(*b)?, CurveKind::Circle { .. })
512                    || !shared
513                        && (is_round(*a)? && (is_round(*b)? || is_line(*b)?)
514                            || is_line(*a)? && is_round(*b)?);
515                need(
516                    ok,
517                    "curves sharing an endpoint (not two lines), a line and a circle/arc, or two circles/arcs",
518                )
519            }
520            Equal { a, b } => need(
521                is_line(*a)? && is_line(*b)? || is_round(*a)? && is_round(*b)?,
522                "two lines or two circles/arcs",
523            ),
524            Concentric { a, b } => need(is_round(*a)? && is_round(*b)?, "two circles/arcs"),
525            Midpoint { curve, .. } => need(
526                matches!(
527                    kind(*curve)?,
528                    CurveKind::Line { .. } | CurveKind::Arc { .. }
529                ),
530                "a point and a line or arc",
531            ),
532            Symmetric { line, .. } => need(is_line(*line)?, "two points and a line"),
533            PointLineDistance { line, .. } => need(is_line(*line)?, "a point and a line"),
534            Length { curve, .. } => need(
535                matches!(
536                    kind(*curve)?,
537                    CurveKind::Line { .. } | CurveKind::Arc { .. }
538                ),
539                "a line or arc",
540            ),
541            Radius { curve, .. } => need(is_round(*curve)?, "a circle or arc"),
542        }
543    }
544
545    /// Union-find representative of every point under
546    /// [`Constraint::Coincident`]: points with the same representative are
547    /// one point.
548    pub fn point_classes(&self) -> BTreeMap<PointId, PointId> {
549        fn find(parent: &mut BTreeMap<PointId, PointId>, i: PointId) -> PointId {
550            let mut r = i;
551            while parent[&r] != r {
552                r = parent[&r];
553            }
554            let mut i = i;
555            while parent[&i] != r {
556                let next = parent[&i];
557                parent.insert(i, r);
558                i = next;
559            }
560            r
561        }
562        let mut parent: BTreeMap<PointId, PointId> =
563            self.points.keys().map(|&id| (id, id)).collect();
564        for c in self.constraints.values() {
565            if let Constraint::Coincident { a, b } = *c {
566                let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
567                // The lower id represents the class, so the representative
568                // does not depend on constraint order.
569                let (lo, hi) = (ra.min(rb), ra.max(rb));
570                parent.insert(hi, lo);
571            }
572        }
573        self.points
574            .keys()
575            .map(|&id| (id, find(&mut parent, id)))
576            .collect()
577    }
578
579    /// Every point the constraints put on the infinite line through the line
580    /// `line`: its endpoints, points constrained onto it
581    /// ([`Constraint::PointOnCurve`], a [`Constraint::Midpoint`] of it, an
582    /// endpoint of a [`Constraint::Collinear`] partner), and any point
583    /// coincident with one of those.
584    ///
585    /// Structural, like all connectivity here: a point that merely happens
586    /// to lie on the line is not reported.
587    pub fn on_line(&self, line: CurveId) -> GeopResult<BTreeSet<PointId>> {
588        let CurveKind::Line { start, end } = self.curve(line)?.kind else {
589            return Err(GeopError::new(format!("curve {line} is not a line")));
590        };
591        let class = self.point_classes();
592        let mut on = BTreeSet::new();
593        let mut mark = |p: PointId| {
594            on.insert(class[&p]);
595        };
596        mark(start);
597        mark(end);
598        for c in self.constraints.values() {
599            match *c {
600                Constraint::PointOnCurve { point, curve }
601                | Constraint::Midpoint { point, curve }
602                    if curve == line =>
603                {
604                    mark(point)
605                }
606                Constraint::Collinear { a, b } if a == line || b == line => {
607                    let other = if a == line { b } else { a };
608                    for p in self.curve(other)?.points() {
609                        mark(p);
610                    }
611                }
612                _ => {}
613            }
614        }
615        Ok(class
616            .iter()
617            .filter(|(_, rep)| on.contains(rep))
618            .map(|(&p, _)| p)
619            .collect())
620    }
621
622    /// Which ends `(a_at_end, b_at_end)` of open curves `a` and `b` are the
623    /// same point, if any (`false` = start, `true` = end).
624    pub fn shared_endpoint(&self, a: CurveId, b: CurveId) -> GeopResult<Option<(bool, bool)>> {
625        let (Some((a0, a1)), Some((b0, b1))) =
626            (self.curve(a)?.endpoints(), self.curve(b)?.endpoints())
627        else {
628            return Ok(None);
629        };
630        let class = self.point_classes();
631        let same = |p: PointId, q: PointId| class[&p] == class[&q];
632        Ok([(false, false), (false, true), (true, false), (true, true)]
633            .into_iter()
634            .find(|&(ea, eb)| same(if ea { a1 } else { a0 }, if eb { b1 } else { b0 })))
635    }
636}