1use geop_core_editor::{
4 edit_form::{EditForm, FormState},
5 editable::{Context, OperationGroup, OperationInfo, OperationTier},
6 editing::{Number, Unit},
7 one_of, part_operation,
8 part_operation::PartOperation,
9};
10use geop_core_program::{
11 element::ElementReference, numeric::NumericInput, part::StepContext, reader::Reader,
12};
13use std::collections::BTreeSet;
14
15use geop_core_geometry::{
16 search::{MAX_NODES, min_subdivision_size},
17 shape::Axis,
18};
19use geop_core_math::{
20 geop_error::{GeopError, GeopResult, WithContext},
21 primitives::CoordinateSystem,
22 scalars::Scalar,
23 vector::{Vector2, Vector3},
24 with_context,
25};
26use geop_core_sketch::{
27 CurveKind, Enclosure, PointId, ProfileJoint, ProfileLoop, Shape, profile::curve_nurbs,
28};
29use geop_core_topology::build::BuiltBody;
30use geop_ops_model::datums::Datums;
31use geop_ops_model::sketch::{PlacedSketch, SketchRef, chain_of_pieces};
32use geop_ops_model::{Extent, Line, ModelOperation, NamedModel, Namer, build_model, read_model};
33use serde::{Deserialize, Serialize};
34
35use geop_lib_sweep::{SweepLoop, revolution, revolve};
36
37use crate::sweeping::{Extents, Output, Sweeping, build_extents};
38
39one_of! {
40 pub enum RevolveAxis -> Axis {
43 Sketch(ElementReference<PlacedSketch, Line>),
44 Datum(ElementReference<Datums, Line>),
45 Edge(ElementReference<NamedModel, Line>),
46 }
47}
48
49#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
86pub struct Revolve;
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct RevolveArgs {
90 pub sketch: SketchRef,
92 pub axis: Option<RevolveAxis>,
95 pub extent: Extents,
99 #[serde(default)]
101 pub output: Output,
102}
103
104struct SketchAxis {
106 point: Vector2,
108 direction: Vector2,
109 drawn: (Vector2, Vector2),
112 on_axis: BTreeSet<PointId>,
115}
116
117impl SketchAxis {
118 fn of(
123 axis: &RevolveAxis,
124 reader: &Reader,
125 sketch: &SketchRef,
126 placed: &PlacedSketch,
127 geometry: &Enclosure,
128 ) -> GeopResult<Self> {
129 let ctx = with_context!("revolving around {axis:?}");
130 let name = &placed.name;
131 let unit = |d: Vector2| d.normalize().with_context(ctx);
132 if let RevolveAxis::Sketch(line) = axis
133 && line.target() == sketch.target()
134 {
135 let curve = line.name().parse().with_context(ctx)?;
136 let sketch = &placed.sketch;
137 let CurveKind::Line { start, end } = sketch.curve(curve).with_context(ctx)?.kind else {
138 return Err(GeopError::new(format!("{curve} is not a line"))).with_context(ctx);
139 };
140 let drawn = |p: PointId| sketch.points[&p].xy();
141 let point = geometry.points[&start];
142 return Ok(SketchAxis {
143 point,
144 direction: unit(geometry.points[&end].sub(&point))?,
145 drawn: (drawn(start), drawn(end).sub(&drawn(start))),
146 on_axis: sketch.on_line(curve).with_context(ctx)?,
147 });
148 }
149 let line = axis.resolve(reader).with_context(ctx)?;
150 let plane = &placed.plane;
151 let point = plane.to_uvw(&line.point);
152 let along = |axis: &Vector3| line.direction.prod_dot(axis);
153 let direction = [along(plane.u()), along(plane.v()), along(plane.w())];
154 if !point[2].could_be_equal(Scalar::ZERO) || !direction[2].could_be_equal(Scalar::ZERO) {
155 return Err(GeopError::new(format!(
156 "it does not lie in the plane of sketch {name:?}"
157 )))
158 .with_context(ctx);
159 }
160 let point = Vector2::from_array([point[0], point[1]]);
161 let direction = unit(Vector2::from_array([direction[0], direction[1]]))?;
162 Ok(SketchAxis {
163 drawn: (point, direction),
164 point,
165 direction,
166 on_axis: BTreeSet::new(),
167 })
168 }
169}
170
171part_operation!(Revolve(RevolveArgs));
172
173impl PartOperation for Revolve {
174 const KIND: &'static str = "revolve";
175 const INFO: OperationInfo = OperationInfo::new(
176 "Revolve",
177 "Sweep a sketch's area around one of its lines into a solid, or its curves into faces.",
178 OperationGroup::Solid,
179 OperationTier::Big,
180 )
181 .shortcut("R")
182 .icon(crate::icons::REVOLVE);
183 type Args = RevolveArgs;
184 type Session = ();
185
186 fn new_args(context: &Context) -> RevolveArgs {
190 let before = &context.before;
191 let newest = before.targets_of::<PlacedSketch>().pop();
192 let sketch = SketchRef::whole(newest.unwrap_or_else(|| geop_ops_model::sketch::sketch("")));
193 RevolveArgs {
194 axis: None,
195 sketch,
196 extent: Extents::blind(360.0),
197 output: Output::new_for(&read_model(before).unwrap_or_default()),
198 }
199 }
200
201 fn run(ctx: &mut StepContext, args: &RevolveArgs) -> GeopResult<()> {
202 let _step = ctx.step();
203 build_model::<Self>(ctx, args)
204 }
205
206 fn form<'a>(
210 context: &'a Context<'a>,
211 args: &RevolveArgs,
212 _: &FormState<()>,
213 ) -> EditForm<'a, RevolveArgs> {
214 let before = &context.before;
215 let mut f = EditForm::<RevolveArgs>::default();
216 f.pick_one(
217 "sketch",
218 "sketch",
219 before,
220 Some(&args.sketch),
221 move |edit, picked| {
222 let args = edit.args;
223 let old = std::mem::replace(&mut args.sketch, picked);
224 if let Some(RevolveAxis::Sketch(line)) = &args.axis
225 && line.target() == old.target()
226 {
227 args.axis = None;
228 }
229 },
230 );
231 f.pick_one(
232 "axis",
233 "axis",
234 before,
235 args.axis.as_ref(),
236 |edit, picked| edit.args.axis = Some(picked),
237 );
238 let amount = |angle: &NumericInput, second| {
239 let label = if second { "angle 2" } else { "angle" };
240 Number::numeric(angle, label, before, Unit::Angle).range(-360.0, 360.0)
241 };
242 args.extent.fields(
243 &mut f,
244 ("angle", 360.0),
245 amount,
246 |args| &mut args.extent,
247 |edit, angle| edit.args.extent.side1 = Extent::Blind(angle),
248 );
249 let reaches = args.extent.reaches_target();
250 let output = |args: &mut RevolveArgs, output| args.output = output;
251 (args.output).fields(&mut f, before, reaches, output);
252 f
253 }
254}
255
256impl ModelOperation for Revolve {
257 fn build(
258 model: &mut NamedModel,
259 reader: &Reader,
260 step: &str,
261 args: &RevolveArgs,
262 ) -> GeopResult<()> {
263 let namer = Namer::new("revolve", step)?;
264 let placed = reader.retrieve_element(&args.sketch).concerning("sketch")?;
265 let sketch = &placed.sketch;
266 let Some(axis) = &args.axis else {
267 return Err(GeopError::new("pick a line to revolve around"));
268 };
269 let geometry = sketch.enclose()?;
270 let axis = SketchAxis::of(axis, reader, &args.sketch, placed, &geometry)?;
271 let (a, dir) = axis.drawn;
272 let axis_left = axis.direction.perp();
273 let on_axis: BTreeSet<PointId> = (geometry.points.iter())
279 .filter(|(p, at)| {
280 let off = at.sub(&axis.point).prod_dot(&axis_left);
281 axis.on_axis.contains(p) || off.could_be_equal(Scalar::ZERO)
282 })
283 .map(|(&p, _)| p)
284 .collect();
285
286 let shape = args.output.shape(sketch)?;
289 args.extent.sweeps(&shape)?;
290 let (loops, closed): (Vec<ProfileLoop>, bool) = match shape {
291 Shape::Regions(regions) => (
292 (regions.into_iter())
293 .flat_map(|r| std::iter::once(r.outer).chain(r.holes))
294 .collect(),
295 true,
296 ),
297 Shape::Chain(chain) => (vec![chain], false),
298 };
299
300 let mut left = false;
308 let mut right = false;
309 for edge in loops.iter().flat_map(|l| &l.edges) {
310 let ends = sketch.curves[&edge.curve].endpoints();
311 let on = |p: Option<PointId>| p.is_some_and(|p| on_axis.contains(&p));
312 let (start_on, end_on) = (on(ends.map(|e| e.0)), on(ends.map(|e| e.1)));
313 if start_on
314 && end_on
315 && matches!(sketch.curves[&edge.curve].kind, CurveKind::Line { .. })
316 {
317 continue;
318 }
319 let pieces = curve_nurbs(sketch, &geometry, edge.curve)?;
320 let last = pieces.len().saturating_sub(1);
321 for (k, piece) in pieces.iter().enumerate() {
322 let (t0, t1) = piece.domain();
323 let side_at = |t: Scalar| -> GeopResult<Scalar> {
324 Ok(dir.prod_cross(&piece.evaluate(t)?.sub(&a)))
325 };
326 let mut ends = Vec::new();
327 if !(k == 0 && start_on) {
328 ends.push(t0);
329 }
330 if !(k == last && end_on) {
331 ends.push(t1);
332 }
333 let at_constrained_end = |t: &Scalar| {
338 (k == 0 && start_on && t.could_be_equal(t0))
339 || (k == last && end_on && t.could_be_equal(t1))
340 };
341 let interior = piece
342 .stationary_parameters(&dir.perp(), MAX_NODES, min_subdivision_size())?
343 .into_iter()
344 .filter(|t| !at_constrained_end(t));
345 for t in interior {
346 let side = side_at(t)?;
347 if side.could_be_equal(Scalar::ZERO) {
348 return Err(GeopError::new(format!(
349 "the curve {},{} touches the revolve axis inside it, at parameter {t:?}: split it there at a point on the axis, or keep it clear of the axis",
350 placed.name, edge.curve
351 )));
352 }
353 left |= side.definitely_greater(Scalar::ZERO);
354 right |= side.definitely_less(Scalar::ZERO);
355 }
356 for t in ends {
357 let side = side_at(t)?;
358 left |= side.definitely_greater(Scalar::ZERO);
359 right |= side.definitely_less(Scalar::ZERO);
360 }
361 }
362 }
363 if left && right {
364 return Err(GeopError::new("the profile crosses the revolve axis"));
365 }
366 if !left && !right {
367 return Err(GeopError::new("the profile lies on the revolve axis"));
368 }
369
370 let r_dir = axis_left.prod_scalar(if left { Scalar::ONE } else { Scalar::ONE.neg() });
375 let z_dir = r_dir.perp();
376 let rz = Enclosure {
377 points: geometry
378 .points
379 .iter()
380 .map(|(&p, xy)| {
381 let d = xy.sub(&axis.point);
382 let r = if on_axis.contains(&p) {
383 Scalar::ZERO
384 } else {
385 d.prod_dot(&r_dir)
386 };
387 (p, Vector2::from_array([r, d.prod_dot(&z_dir)]))
388 })
389 .collect(),
390 params: geometry.params.clone(),
391 };
392 let pole =
393 |joint: &ProfileJoint| matches!(joint, ProfileJoint::Point(p) if on_axis.contains(p));
394 let sweep_loops = loops
395 .iter()
396 .map(|lp| {
397 let pieces = lp.to_nurbs(sketch, &rz)?;
398 let mut poles: Vec<bool> = pieces.iter().map(|p| pole(&p.start)).collect();
399 if !closed && let Some(last) = pieces.last() {
400 poles.push(pole(&last.end));
401 }
402 let along_axis: Vec<bool> = pieces
403 .iter()
404 .map(|p| match sketch.curves[&p.source].kind {
405 CurveKind::Line { start, end } => {
406 on_axis.contains(&start) && on_axis.contains(&end)
407 }
408 _ => false,
409 })
410 .collect();
411 let profile = chain_of_pieces(&placed.name, pieces, closed);
412 for (i, &is_pole) in poles.iter().enumerate() {
413 let at = profile.joint(i)?;
414 if !is_pole && at[0].could_be_equal(Scalar::ZERO) {
415 return Err(GeopError::new(format!(
416 "the profile touches the revolve axis at {}, where no point of the sketch is: put one there, or keep the profile clear of the axis",
417 profile.joint_names[i]
418 )));
419 }
420 }
421 Ok(SweepLoop {
422 profile,
423 poles,
424 on_axis: along_axis,
425 apex: false,
426 })
427 })
428 .collect::<GeopResult<Vec<_>>>()
429 ?;
430
431 let plane = &placed.plane;
432 let (u, w) = (plane.uv_direction(&r_dir), plane.uv_direction(&z_dir));
433 let origin = plane.uv_to_xyz(&axis.point);
434 let axes = CoordinateSystem::try_from_xz(origin, u, w)?;
435
436 let revolving = Revolving {
437 axes: &axes,
438 loops: &sweep_loops,
439 };
440 let how = (&args.extent, &args.output);
441 build_extents(model, reader, &namer, step, how, &revolving)
442 }
443}
444
445struct Revolving<'a> {
448 axes: &'a CoordinateSystem,
449 loops: &'a [SweepLoop],
450}
451
452impl Sweeping for Revolving<'_> {
453 fn loops(&self) -> &[SweepLoop] {
454 self.loops
455 }
456
457 fn sweep(
458 &self,
459 model: &mut NamedModel,
460 namer: &Namer,
461 name: Option<&str>,
462 from: f64,
463 to: f64,
464 ) -> GeopResult<BuiltBody> {
465 revolve(model, namer, name, self.axes, from, to, self.loops)
466 }
467
468 fn past(&self, hull: &[Vector3], sign: f64) -> GeopResult<f64> {
469 turn_past(hull, self.axes, sign)
470 }
471
472 fn ends(&self, to: f64) -> GeopResult<(String, String)> {
473 let stations = revolution(self.axes, 0.0, to)?.station_names;
474 Ok((stations[0].clone(), stations[stations.len() - 1].clone()))
475 }
476
477 fn all_the_way(&self, _: f64) -> f64 {
478 360.0
479 }
480
481 fn full_turn(&self) -> Option<f64> {
482 Some(360.0)
483 }
484
485 fn measure(&self, sign: f64, p: &Vector3) -> Option<f64> {
486 turned_angle(self.axes, p, sign)
487 }
488}
489
490fn turned_angle(axes: &CoordinateSystem, p: &Vector3, sign: f64) -> Option<f64> {
495 let q = axes.to_uvw(p);
496 let on_axis = q[0].could_be_equal(Scalar::ZERO) && q[1].could_be_equal(Scalar::ZERO);
497 let at_start = q[1].could_be_equal(Scalar::ZERO) && q[0].definitely_greater(Scalar::ZERO);
498 if on_axis || at_start {
499 return None;
500 }
501 let angle = (sign * q[1].to_f64().atan2(q[0].to_f64()).to_degrees()).rem_euclid(360.0);
502 (angle > 0.0 && angle < 360.0).then_some(angle)
503}
504
505fn turn_past(hull: &[Vector3], axes: &CoordinateSystem, sign: f64) -> GeopResult<f64> {
529 let surrounds = |detail: String| {
530 surrounds(format!(
531 "{detail}; the axis through {:?} along {:?}",
532 axes.origin(),
533 axes.w()
534 ))
535 };
536 let turned = |degrees: f64| match degrees.rem_euclid(360.0) {
539 a if a >= 360.0 => 0.0,
540 a => a,
541 };
542 let mut seen = Vec::with_capacity(hull.len());
545 for p in hull {
546 let q = axes.to_uvw(p);
547 let (x, y) = (q[0], q[1].mul(Scalar::from_f64(sign)));
548 if x.could_be_equal(Scalar::ZERO) && y.could_be_equal(Scalar::ZERO) {
552 continue;
553 }
554 let angle = turned(y.to_f64().atan2(x.to_f64()).to_degrees());
555 seen.push((angle, x, y, p));
556 }
557 if seen.is_empty() {
558 return Err(surrounds(format!(
559 "all of the hull's {} points could lie on the axis",
560 hull.len()
561 )));
562 }
563 seen.sort_by(|a, b| a.0.total_cmp(&b.0));
564 let n = seen.len();
565 let (gap, i) = (0..n)
566 .map(|i| {
567 let next = if i + 1 < n {
568 seen[i + 1].0
569 } else {
570 seen[0].0 + 360.0
571 };
572 (next - seen[i].0, i)
573 })
574 .max_by(|a, b| a.0.total_cmp(&b.0))
575 .expect("a hull has points");
576 let (a, b) = (&seen[i], &seen[(i + 1) % n]);
583 let across = a.1.mul(b.2).sub(a.2.mul(b.1));
584 if gap < 180.0 && across.definitely_greater(Scalar::ZERO) {
585 return Err(surrounds(format!(
586 "the widest gap between the hull's {n} points seen down the axis, from {:?} at {:?} degrees to {:?} at {:?}, is only {gap:?} degrees",
587 a.3, a.0, b.3, b.0,
588 )));
589 }
590 let after = a.0;
593 let b = turned(after + gap);
594 let e = if after == 0.0 { 360.0 } else { after };
595 Ok(if b > e {
596 e + gap / 2.0
598 } else if e < 360.0 {
599 (e + 360.0) / 2.0
600 } else {
601 (b + 360.0) / 2.0
602 })
603}
604
605fn surrounds(detail: String) -> GeopError {
607 GeopError::new(format!(
608 "up to next: the target lies all around the revolve axis, so no turn short of a full one comes past it; give the angle instead ({detail})",
609 ))
610}
611
612#[cfg(test)]
613mod tests {
614 use super::turn_past;
615 use geop_core_math::{primitives::CoordinateSystem, scalars::Scalar, vector::Vector3};
616
617 #[test]
622 fn a_target_against_the_start_plane_stops_the_turn_short_of_a_full_one() {
623 let axes = CoordinateSystem::world_at(Vector3::zero());
624 let hull = [
627 [2.0, -1e-17, 0.0],
628 [3.0, 0.0, 1.0],
629 [-3.0, 0.0, 1.0],
630 [0.0, -3.0, 0.0],
631 [2.0, -2.0, 1.0],
632 ]
633 .map(|p| Vector3::from_array(p.map(Scalar::from_f64)));
634 for sign in [1.0, -1.0] {
635 let far = turn_past(&hull, &axes, sign).unwrap();
636 assert!(far > 0.0 && far < 360.0, "{sign}: {far}");
637 }
638 assert!(turn_past(&hull, &axes, 1.0).unwrap() > 180.0);
641 }
642}