Skip to main content

geop_ops_parts/operation/
revolve.rs

1//! [`Revolve`]: sweep a sketch's regions a full turn around a sketch line.
2
3use geop_core_math::{
4    geop_error::{GeopError, GeopResult, WithContext},
5    primitives::CoordinateSystem,
6    scalars::Scalar,
7    vector::Vector2,
8    with_context,
9};
10use geop_core_part::{Namer, Part};
11use geop_core_sketch::{CurveId, CurveKind, Positions, ProfileLoop};
12use geop_ops_extrude_revolve::revolve::revolve_at_oriented;
13use geop_ops_parts_derive::OperationArgs;
14use serde::{Deserialize, Serialize};
15
16use super::{Combine, Operation, extrude::sketch_profile};
17
18/// Revolves every region of a sketch a full turn around one of its lines,
19/// into one solid named `revolve(R)` for the operation `R` — or, with
20/// [`RevolveArgs::combine`], combines that with another solid (see
21/// [`Combine`]), the result named `revolve(R)` all the same.
22///
23/// Each region must touch the axis along an edge — a line whose endpoints the
24/// constraints put on the axis (see `Sketch::on_line`), typically the axis
25/// line itself — and lie entirely on one side of it, the same side for every
26/// region. The rest of its boundary is the profile that sweeps out the
27/// solid, and names it (see
28/// `geop_ops_extrude_revolve::revolve::revolve_at_oriented`): with `X` a
29/// piece of a sketch curve and `P` a joint of the sketch `K`, as for
30/// [`super::Extrude`],
31///
32/// - `revolve(R,K,X,q0)` .. `q3`: the face `X` sweeps through each quarter
33///   turn, starting from the sketch plane;
34/// - `revolve(R,K,X,a0)` .. `a3`: `X` itself at each quarter angle (`a0` is
35///   the profile where it was drawn);
36/// - `revolve(R,K,P,q0)` .. / `revolve(R,K,P,a0)` ..: the circular edges and
37///   vertices `P` sweeps, and `revolve(R,K,P)` for a `P` on the axis.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
39pub struct Revolve;
40
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, OperationArgs)]
42pub struct RevolveArgs {
43    /// The sketch to revolve.
44    #[arg(Sketch)]
45    pub sketch: String,
46    /// The line of that sketch to revolve around. The profile must touch it
47    /// along an edge.
48    #[arg(SketchLine { sketch: "sketch" })]
49    pub axis: CurveId,
50    /// Keep the solid as a new body, or combine it with another solid.
51    #[serde(default)]
52    #[arg(Combine { sign: None })]
53    pub combine: Combine,
54}
55
56impl<S: Scalar> Operation<S> for Revolve {
57    type Args = RevolveArgs;
58
59    fn apply(
60        &self,
61        mut part: Part<S>,
62        operation_id: &str,
63        args: &RevolveArgs,
64    ) -> GeopResult<Part<S>> {
65        let ctx = with_context!("revolve({operation_id}, {args:?})");
66        let namer = Namer::new("revolve", operation_id)?;
67        let placed = part
68            .sketch(part.sketch_id(&args.sketch).with_context(ctx)?)?
69            .clone();
70        let sketch = &placed.sketch;
71        let CurveKind::Line { start, end } = sketch.curve(args.axis).with_context(ctx)?.kind else {
72            return Err(GeopError::new(format!(
73                "revolve axis {} is not a line",
74                args.axis
75            )))
76            .with_context(ctx);
77        };
78        let positions = sketch.positions();
79        let a = positions[&start];
80        let b = positions[&end];
81        let len = (b[0] - a[0]).hypot(b[1] - a[1]);
82        let dir = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
83        let left = [-dir[1], dir[0]];
84        let on_axis = sketch.on_line(args.axis)?;
85
86        let regions = sketch.regions().with_context(ctx)?;
87        let mut sides = Vec::new();
88        let mut solid = None;
89        for (index, region) in regions.iter().enumerate() {
90            let ctx = with_context!("revolving sketch region {index}");
91            if !region.holes.is_empty() {
92                return Err(GeopError::new(
93                    "revolving a region with holes is not supported yet",
94                ))
95                .with_context(ctx);
96            }
97            // Which side of the axis the region lies on, from its outline; it
98            // must not cross. Vertices on the axis sit within rounding of it on
99            // either side, so crossing means reaching measurably across,
100            // relative to the region's extent — a classification of design
101            // data, like the nesting test in `Sketch::regions`.
102            let outline = region.outer.polyline(sketch, &positions);
103            let side = |p: &[f64; 2]| (p[0] - a[0]) * left[0] + (p[1] - a[1]) * left[1];
104            let (lo, hi) = outline
105                .iter()
106                .map(side)
107                .fold((0.0f64, 0.0f64), |(lo, hi), s| (lo.min(s), hi.max(s)));
108            let scale = hi - lo;
109            if lo < -1e-9 * scale && hi > 1e-9 * scale {
110                return Err(GeopError::new("the profile crosses the revolve axis"))
111                    .with_context(ctx);
112            }
113            let sign = if hi > -lo { 1.0 } else { -1.0 };
114            sides.push(sign);
115            if sides.iter().any(|&s| s != sign) {
116                return Err(GeopError::new(
117                    "the sketch has regions on both sides of the revolve axis, which would overlap \
118                     once revolved; revolve them in separate operations",
119                ))
120                .with_context(ctx);
121            }
122
123            // `(r, z)` coordinates: `r` towards the region, `z` along the axis,
124            // oriented like the sketch (`z` is `r` turned counter-clockwise), so
125            // loops keep their winding. Points the constraints put on the axis
126            // get `r = 0` exactly — the solver only approaches it.
127            let r_dir = [sign * left[0], sign * left[1]];
128            let z_dir = [-r_dir[1], r_dir[0]];
129            let rz: Positions = positions
130                .iter()
131                .map(|(&p, xy)| {
132                    let d = [xy[0] - a[0], xy[1] - a[1]];
133                    let r = if on_axis.contains(&p) {
134                        0.0
135                    } else {
136                        d[0] * r_dir[0] + d[1] * r_dir[1]
137                    };
138                    (p, [r, d[0] * z_dir[0] + d[1] * z_dir[1]])
139                })
140                .collect();
141
142            // The profile is the outer loop minus its run of edges on the
143            // axis, walked top-down (see `revolve_at_oriented`): the loop is
144            // counter-clockwise, so the region lies left of it, and reversing
145            // puts it on the right.
146            let edges = &region.outer.edges;
147            let flags: Vec<bool> = edges
148                .iter()
149                .map(|e| match sketch.curves[&e.curve].kind {
150                    CurveKind::Line { start, end } => {
151                        on_axis.contains(&start) && on_axis.contains(&end)
152                    }
153                    _ => false,
154                })
155                .collect();
156            let n = edges.len();
157            let starts_off_axis = |k: usize| !flags[k] && flags[(k + n - 1) % n];
158            if (0..n).filter(|&k| starts_off_axis(k)).count() != 1 {
159                return Err(GeopError::new(
160                    "the profile must touch the revolve axis along exactly one run of edges \
161                     (constrain its edge onto the axis line)",
162                ))
163                .with_context(ctx);
164            }
165            let first_off = (0..n).find(|&k| starts_off_axis(k)).unwrap();
166            let chain = ProfileLoop {
167                edges: (0..n)
168                    .map(|k| (first_off + k) % n)
169                    .take_while(|&k| !flags[k])
170                    .map(|k| edges[k])
171                    .collect(),
172            }
173            .reversed();
174            let profile = sketch_profile(
175                &args.sketch,
176                chain.to_nurbs(sketch, &rz).with_context(ctx)?,
177                false,
178            );
179
180            let plane = &placed.plane;
181            let dir3 = |d: [f64; 2]| {
182                plane
183                    .u()
184                    .prod_scalar(S::from_f64(d[0]))
185                    .add(&plane.v().prod_scalar(S::from_f64(d[1])))
186            };
187            let (u, w) = (dir3(r_dir), dir3(z_dir));
188            let origin =
189                plane.uv_to_xyz(&Vector2::from_array([S::from_f64(a[0]), S::from_f64(a[1])]));
190            let cs = CoordinateSystem::try_new(origin, u, w.prod_cross(&u), w)?;
191            // Every region after the first is merged into the first, so its
192            // own solid name only exists until then.
193            let solid_name = match solid {
194                None => args.combine.built_name(&namer),
195                Some(_) => namer.name(&["solid", &profile.curve_names[0]]),
196            };
197            let built = revolve_at_oriented(&mut part, &namer, &solid_name, &profile, &cs)
198                .with_context(ctx)?;
199            match solid {
200                None => solid = Some(built),
201                Some(first) => part.merge_solids(first, built)?,
202            }
203        }
204        if let Some(built) = solid {
205            args.combine
206                .apply(&mut part, &namer, operation_id, built)
207                .with_context(ctx)?;
208        }
209        Ok(part)
210    }
211}