Skip to main content

geop_ops_pattern/
deform_body.rs

1//! [`DeformBody`]: bodies tapered, pinched, sheared, twisted or bent about
2//! an axis.
3
4use geop_core_editor::{
5    edit_form::{EditForm, FormState, choices, chosen},
6    editable::{Context, OperationGroup, OperationInfo, OperationTier},
7    editing::{Number, Unit},
8    part_operation,
9    part_operation::PartOperation,
10};
11use geop_core_geometry::deform::AxialMap;
12use geop_core_math::{
13    geop_error::{GeopError, GeopResult},
14    scalars::Scalar,
15    vector::Vector3,
16};
17use geop_core_program::{part::StepContext, reader::Reader};
18use geop_core_topology::{Body, build::BodySpec};
19use geop_lib_booleans::{RemeshParams, imprint_on_face};
20use geop_ops_model::{
21    BodyNames, ModelOperation, NamedModel, Namer, build_model, read_model, resolve_bodies,
22};
23use serde::{Deserialize, Serialize};
24
25use crate::common::{BodyRef, bodies_field, body};
26
27/// How a [`DeformBody`] deforms: each about the axis, over the zone.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Deform {
31    /// Scaled across the axis, by one at the zone's start and by `amount`
32    /// at its end, linearly in between: exact.
33    #[default]
34    Taper,
35    /// Scaled across the axis only towards `toward`, as a taper is: exact.
36    Pinch,
37    /// Moved towards `toward`, by nothing at the zone's start and by
38    /// `amount` at its end, linearly along the whole axis: exact, an affine
39    /// map, which the zone only gives its rate.
40    Shear,
41    /// Turned about the axis, by nothing at the zone's start and by
42    /// `amount` degrees at its end, uniformly in between.
43    Twist,
44    /// The zone wound onto an arc of `amount` degrees, curving towards
45    /// `toward`, its length kept along the axis.
46    Bend,
47}
48
49const DEFORMS: [(Deform, &str, &str); 5] = [
50    (Deform::Taper, "taper", "Taper"),
51    (Deform::Pinch, "pinch", "Pinch"),
52    (Deform::Shear, "shear", "Shear"),
53    (Deform::Twist, "twist", "Twist"),
54    (Deform::Bend, "bend", "Bend"),
55];
56
57/// Tapers, pinches, shears, twists or bends the bodies `bodies` — solids,
58/// and sheets by one of their faces — about the axis through `origin` along
59/// `axis`, over the zone from `start` to `end` along it (measured from
60/// `origin`): in place, every entity keeping its name. Before the zone a
61/// body stays where it is; past it, it is carried on as the zone's end
62/// leaves it — turned rigidly by a twist or a bend, scaled across the axis
63/// by a taper or a pinch. A shear is affine: one map along the whole axis,
64/// so nothing is divided at the zone's ends.
65///
66/// Where a body crosses an end of the zone, its faces are first divided
67/// there (as a curve projected onto them divides them), so that each piece
68/// lies on one side. Every curve and surface is then composed with the
69/// deformation on its control points (see
70/// [`geop_core_geometry::deform`]): exactly for a taper, to within a proven
71/// bound that widens the result for a twist or a bend. Each surface keeps
72/// its parametrization, so every pcurve stays as it is.
73///
74/// Refused: a zone that does not run forward, a taper to a factor that is
75/// not positive, a bend of a whole turn or more or one that would wind
76/// material past its centre, and a zone end through a corner of a body
77/// that crosses it (move the zone).
78#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
79pub struct DeformBody;
80
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82pub struct DeformBodyArgs {
83    /// The bodies to deform: solids, or a face of each sheet.
84    pub bodies: Vec<BodyRef>,
85    /// Taper, pinch, shear, twist or bend.
86    #[serde(default)]
87    pub deform: Deform,
88    /// A point of the axis, from which the zone is measured.
89    #[serde(default)]
90    pub origin: [f64; 3],
91    /// The axis's direction.
92    pub axis: [f64; 3],
93    /// Where a bend curves towards, a pinch scales along, a shear moves
94    /// towards: a direction across the axis.
95    #[serde(default)]
96    pub toward: [f64; 3],
97    /// Where the zone starts and ends along the axis.
98    pub start: f64,
99    pub end: f64,
100    /// The taper's or the pinch's factor at the zone's end, the shear's
101    /// offset there, or the twist's or the bend's angle in degrees.
102    pub amount: f64,
103}
104
105impl DeformBodyArgs {
106    /// The map these arguments ask for.
107    fn map(&self) -> GeopResult<AxialMap> {
108        let numbers = (self.origin.iter())
109            .chain(&self.axis)
110            .chain(&self.toward)
111            .chain([&self.start, &self.end, &self.amount]);
112        if let Some(bad) = numbers.clone().find(|v| !v.is_finite()) {
113            return Err(GeopError::new(format!("{bad} is not a number")));
114        }
115        let point = |p: [f64; 3]| Vector3::from_array(p.map(Scalar::from_f64));
116        let (origin, axis) = (point(self.origin), point(self.axis));
117        let zone = (Scalar::from_f64(self.start), Scalar::from_f64(self.end));
118        let amount = Scalar::from_f64(self.amount);
119        match self.deform {
120            Deform::Taper => AxialMap::taper(origin, &axis, None, zone, amount),
121            Deform::Pinch => {
122                AxialMap::taper(origin, &axis, Some(&point(self.toward)), zone, amount)
123            }
124            Deform::Shear => AxialMap::shear(origin, &axis, &point(self.toward), zone, amount),
125            Deform::Twist => AxialMap::twist(origin, &axis, zone, amount.degrees_to_radians()?),
126            Deform::Bend => AxialMap::bend(
127                origin,
128                &axis,
129                &point(self.toward),
130                zone,
131                amount.degrees_to_radians()?,
132            ),
133        }
134    }
135}
136
137part_operation!(DeformBody(DeformBodyArgs));
138
139impl PartOperation for DeformBody {
140    const KIND: &'static str = "deform_body";
141    const INFO: OperationInfo = OperationInfo::new(
142        "Deform body",
143        "Taper, pinch, shear, twist or bend bodies about an axis, over a zone along it.",
144        OperationGroup::Bodies,
145        OperationTier::Menu,
146    )
147    .icon(crate::icons::DEFORM_BODY);
148    type Args = DeformBodyArgs;
149    type Session = ();
150
151    /// The newest solid, tapered along `z` over its whole height to half
152    /// its width.
153    fn new_args(context: &Context) -> DeformBodyArgs {
154        let model = read_model(&context.before).ok();
155        let solid = model.as_ref().and_then(|m| m.newest_solid());
156        let (start, end) = (model.as_ref().zip(solid.as_ref()))
157            .and_then(|(m, s)| height(m, s))
158            .unwrap_or((0.0, 1.0));
159        DeformBodyArgs {
160            bodies: solid.map(body).into_iter().collect(),
161            deform: Deform::Taper,
162            origin: [0.0; 3],
163            axis: [0.0, 0.0, 1.0],
164            toward: [1.0, 0.0, 0.0],
165            start,
166            end,
167            amount: 0.5,
168        }
169    }
170
171    fn run(ctx: &mut StepContext, args: &DeformBodyArgs) -> GeopResult<()> {
172        let _step = ctx.step();
173        build_model::<Self>(ctx, args)
174    }
175
176    /// The bodies, picked; the deformation, the axis, the zone and the
177    /// amount — the toward direction for a pinch, a shear or a bend.
178    fn form<'a>(
179        context: &'a Context<'a>,
180        args: &DeformBodyArgs,
181        _: &FormState<()>,
182    ) -> EditForm<'a, DeformBodyArgs> {
183        let mut f = EditForm::<DeformBodyArgs>::default();
184        bodies_field(&mut f, &context.before, &args.bodies, |edit, picked| {
185            edit.args.bodies = picked
186        });
187        let (value, options) = choices(&DEFORMS, &args.deform);
188        f.select("deform", "deform", value, options, false, |edit, value| {
189            if let Some(deform) = chosen(&DEFORMS, value) {
190                edit.args.deform = deform;
191            }
192        });
193        let amount = match args.deform {
194            Deform::Taper | Deform::Pinch => Number::new("factor", args.amount, Unit::Fraction),
195            Deform::Shear => Number::new("offset", args.amount, Unit::Length),
196            Deform::Twist | Deform::Bend => Number::new("angle", args.amount, Unit::Angle),
197        };
198        f.number("amount", amount, |edit, v| edit.args.amount = v);
199        for (key, label, value) in [
200            ("start", "zone start", args.start),
201            ("end", "zone end", args.end),
202        ] {
203            let number = Number::new(label, value, Unit::Length);
204            f.number(key, number, move |edit, v| match key {
205                "start" => edit.args.start = v,
206                _ => edit.args.end = v,
207            });
208        }
209        // A field per coordinate of a vector of the arguments, keyed
210        // `keys`, setting what `field` reaches.
211        type Field = fn(&mut DeformBodyArgs) -> &mut [f64; 3];
212        let mut vector =
213            |keys: [&'static str; 3], label: &str, value: [f64; 3], unit, field: Field| {
214                for (k, (key, axis)) in keys.into_iter().zip(["x", "y", "z"]).enumerate() {
215                    let number = Number::new(format!("{label} {axis}"), value[k], unit);
216                    f.number(key, number, move |edit, v| field(edit.args)[k] = v);
217                }
218            };
219        let origin = ["origin_x", "origin_y", "origin_z"];
220        vector(origin, "origin", args.origin, Unit::Length, |a| {
221            &mut a.origin
222        });
223        let axis = ["axis_x", "axis_y", "axis_z"];
224        vector(axis, "axis", args.axis, Unit::Fraction, |a| &mut a.axis);
225        if matches!(args.deform, Deform::Pinch | Deform::Shear | Deform::Bend) {
226            let toward = ["toward_x", "toward_y", "toward_z"];
227            vector(toward, "toward", args.toward, Unit::Fraction, |a| {
228                &mut a.toward
229            });
230        }
231        f
232    }
233}
234
235/// The lowest and the highest `z` of the control points of the solid
236/// `solid`'s faces: the zone a new deformation along `z` spans.
237fn height(model: &NamedModel, solid: &str) -> Option<(f64, f64)> {
238    let id = model.solid_id(solid).ok()?;
239    let faces = model.topology().body_faces(id).ok()?;
240    let z = (faces.iter())
241        .map(|f| model.topology().faces[f].surface.aabb()[2])
242        .reduce(Scalar::union)?;
243    (z.lo.is_finite() && z.hi.is_finite() && z.hi > z.lo).then_some((z.lo, z.hi))
244}
245
246impl ModelOperation for DeformBody {
247    fn build(
248        model: &mut NamedModel,
249        reader: &Reader,
250        step: &str,
251        args: &DeformBodyArgs,
252    ) -> GeopResult<()> {
253        let map = args.map()?;
254        let namer = Namer::new(DeformBody::KIND, step)?;
255        let motion = map.as_motion()?;
256        for body in resolve_bodies(reader, &args.bodies)? {
257            if let Some(motion) = &motion {
258                model.transform_body(body, motion)?;
259                continue;
260            }
261            let (start, end) = map.zone();
262            for (side, t) in [("start", start), ("end", end)] {
263                divide_across(model, &namer.scoped(side), body, &map, t)?;
264            }
265            model.transform_body(body, &map)?;
266        }
267        Ok(())
268    }
269}
270
271/// Divides the faces of `body` that cross the plane across `map`'s axis at
272/// `t`, each along where the plane crosses it (see [`imprint_on_face`]), so
273/// that every face lies on one side. Nothing where the body does not reach
274/// definitely across the plane. Refused where a corner of a body that does
275/// could lie on the plane: the cut would land on it.
276fn divide_across(
277    model: &mut NamedModel,
278    namer: &Namer,
279    body: Body,
280    map: &AxialMap,
281    t: Scalar,
282) -> GeopResult<()> {
283    let topology = model.topology();
284    let mut vertices: Vec<_> = topology.iter_body_vertices(body)?.collect();
285    vertices.sort_by_key(|v| v.0);
286    let along = |v| map.along(&topology.vertices[&v].point);
287    let before = vertices.iter().any(|&v| along(v).definitely_less(t));
288    let after = vertices.iter().any(|&v| along(v).definitely_greater(t));
289    if !(before && after) {
290        return Ok(());
291    }
292    if let Some(&on) = vertices.iter().find(|&&v| along(v).could_be_equal(t)) {
293        return Err(GeopError::new(format!(
294            "the zone's end at {t:?} along the axis passes through the corner {} of the body; move the zone off it",
295            model.name_of(on).unwrap_or("?")
296        )));
297    }
298    // Every face with corners on both sides, divided by a sheet of its own
299    // reaching past the whole body.
300    let mut faces = topology.body_faces(body)?;
301    faces.sort_by_key(|f| f.0);
302    let crossing: Vec<_> = (faces.into_iter())
303        .filter(|&f| {
304            let corners: Vec<_> = (topology.face_edges(f))
305                .flat_map(|e| {
306                    [
307                        topology.edges[&e].start_vertex,
308                        topology.edges[&e].end_vertex,
309                    ]
310                })
311                .map(along)
312                .collect();
313            corners.iter().any(|c| c.definitely_less(t))
314                && corners.iter().any(|c| c.definitely_greater(t))
315        })
316        .collect();
317    let reach = (topology.body_faces(body)?.iter())
318        .map(|f| topology.faces[f].surface.size())
319        .try_fold(Scalar::ONE, |sum, s| Ok::<_, GeopError>(sum.add(s?)))?;
320    let reach = reach.add(
321        (vertices.iter())
322            .map(|&v| topology.vertices[&v].point.norm())
323            .fold(Scalar::ZERO, Scalar::max),
324    );
325    for face in crossing {
326        let name = model.name_of(face).unwrap_or("?").to_string();
327        let sheet_namer = namer.scoped(&name);
328        let spec = BodySpec::sheet_on(map.plane_across(t, reach.upper())?)?;
329        let names = BodyNames {
330            vertices: (0..4)
331                .map(|k| sheet_namer.name(&[&format!("v{k}")]))
332                .collect(),
333            edges: (0..4)
334                .map(|k| sheet_namer.name(&[&format!("e{k}")]))
335                .collect(),
336            faces: vec![sheet_namer.name(&["plane"])],
337            solid: None,
338        };
339        let built = model.build_body(spec, names)?;
340        let sheet = *built
341            .shells
342            .first()
343            .ok_or_else(|| GeopError::new("a sheet came out as no shell"))?;
344        imprint_on_face(
345            model,
346            &sheet_namer,
347            face,
348            &[Body::Sheet(sheet)],
349            RemeshParams::default(),
350        )
351        .map_err(|e| e.with_context(format!("dividing face {name} at the zone's end {t:?}")))?;
352    }
353    Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use geop_lib_sweep::shapes::{block_part, cylinder};
360    use geop_ops_model::testing::{apply_to_model, assert_valid};
361
362    fn solid_volume(part: &NamedModel) -> f64 {
363        let solid = part.solid_id(&part.solid_names()[0]).unwrap();
364        (part.topology().mass_properties(solid).unwrap().volume).to_f64()
365    }
366
367    fn deformed(part: NamedModel, args: DeformBodyArgs) -> GeopResult<NamedModel> {
368        apply_to_model::<DeformBody>(part, "d", &args)
369    }
370
371    fn args(part: &NamedModel, deform: Deform, zone: (f64, f64), amount: f64) -> DeformBodyArgs {
372        DeformBodyArgs {
373            bodies: vec![body(part.solid_names()[0].clone())],
374            deform,
375            origin: [0.0; 3],
376            axis: [0.0, 0.0, 1.0],
377            toward: [1.0, 0.0, 0.0],
378            start: zone.0,
379            end: zone.1,
380            amount,
381        }
382    }
383
384    /// A box tapered along its height to half its width: a frustum of a
385    /// pyramid, exactly — its volume `h (A + sqrt(A a) + a) / 3`.
386    #[test]
387    fn a_box_tapered_to_half_is_a_frustum() {
388        let part = block_part("b", [-1.0, -1.0, 0.0], [1.0, 1.0, 3.0]);
389        let part = deformed(part.clone(), args(&part, Deform::Taper, (0.0, 3.0), 0.5)).unwrap();
390        assert_valid(&part);
391        let volume = 3.0 * (4.0 + 2.0 + 1.0) / 3.0;
392        assert!(
393            (solid_volume(&part) - volume).abs() < 1e-9,
394            "{}",
395            solid_volume(&part)
396        );
397    }
398
399    /// A box twisted a quarter turn about its own axis keeps its volume:
400    /// each slice across the axis only turns.
401    #[test]
402    fn a_twisted_box_keeps_its_volume() {
403        let part = block_part("b", [-1.0, -0.5, 0.0], [1.0, 0.5, 4.0]);
404        let part = deformed(part.clone(), args(&part, Deform::Twist, (0.0, 4.0), 90.0)).unwrap();
405        assert_valid(&part);
406        assert!(
407            (solid_volume(&part) - 8.0).abs() < 1e-6,
408            "{}",
409            solid_volume(&part)
410        );
411    }
412
413    /// A bar bent a quarter turn over the middle of its length: the faces
414    /// crossing the zone's ends are divided there, the bar before the zone
415    /// stays where it was and the part past it is carried round the circle
416    /// of radius `4 / π` about `x = 4 / π`, `z = 2`: its far corner `(0.5,
417    /// 0.5, 6)` lands at `(4 / π + 2, 0.5, 1.5 + 4 / π)`.
418    #[test]
419    fn a_bar_bent_over_part_of_its_length() {
420        let part = block_part("b", [-0.5, -0.5, 0.0], [0.5, 0.5, 6.0]);
421        let a = args(&part, Deform::Bend, (2.0, 4.0), 90.0);
422        let part = deformed(part, a).unwrap();
423        assert_valid(&part);
424        let radius = 4.0 / std::f64::consts::PI;
425        let corner = [radius + 2.0, 0.5, 1.5 + radius];
426        let found = (part.topology().vertices.values())
427            .any(|v| (0..3).all(|k| (v.point[k].to_f64() - corner[k]).abs() < 1e-9));
428        assert!(found, "no corner at {corner:?}");
429        // The four sides divided at both ends of the zone.
430        assert_eq!(part.topology().faces.len(), 14);
431    }
432
433    /// A cylinder beside the axis, twisted round it and then tapered: its
434    /// rational patches composed, still a valid solid.
435    #[test]
436    fn a_cylinder_beside_the_axis_twisted_and_tapered() {
437        let mut part = NamedModel::new();
438        cylinder(&mut part, "c", [1.5, 0.0, 0.0], 0.5, 3.0);
439        let part = deformed(part.clone(), args(&part, Deform::Twist, (0.0, 3.0), 60.0)).unwrap();
440        assert_valid(&part);
441        let part = deformed(part.clone(), args(&part, Deform::Taper, (0.0, 3.0), 0.6)).unwrap();
442        assert_valid(&part);
443    }
444
445    /// A cylinder twisted about its own axis is the same cylinder.
446    #[test]
447    fn a_cylinder_twisted_about_its_axis() {
448        let mut part = NamedModel::new();
449        cylinder(&mut part, "c", [0.0; 3], 1.0, 2.0);
450        let part = deformed(part.clone(), args(&part, Deform::Twist, (0.0, 2.0), 45.0)).unwrap();
451        assert_valid(&part);
452        let volume = 2.0 * std::f64::consts::PI;
453        assert!(
454            (solid_volume(&part) - volume).abs() < 1e-6,
455            "{}",
456            solid_volume(&part)
457        );
458    }
459
460    /// A box pinched along `x` to half its width over its height: a wedge
461    /// cut flat, its volume the mean width times the depth and the height.
462    /// A box sheared along `y` keeps its volume, its faces undivided, its
463    /// top moved by the offset.
464    #[test]
465    fn a_box_pinched_and_a_box_sheared() {
466        let part = block_part("b", [-1.0, -1.0, 0.0], [1.0, 1.0, 3.0]);
467        let part = deformed(part.clone(), args(&part, Deform::Pinch, (0.0, 3.0), 0.5)).unwrap();
468        assert_valid(&part);
469        let volume = 3.0 * 2.0 * (2.0 + 1.0) / 2.0;
470        assert!(
471            (solid_volume(&part) - volume).abs() < 1e-9,
472            "{}",
473            solid_volume(&part)
474        );
475
476        let part = block_part("b", [-1.0, -1.0, 0.0], [1.0, 1.0, 3.0]);
477        let mut sheared = args(&part, Deform::Shear, (0.0, 2.0), 1.0);
478        sheared.toward = [0.0, 1.0, 0.0];
479        let part = deformed(part, sheared).unwrap();
480        assert_valid(&part);
481        assert_eq!(part.topology().faces.len(), 6);
482        assert!((solid_volume(&part) - 12.0).abs() < 1e-9);
483        let top = [1.0, 2.5, 3.0];
484        let found = (part.topology().vertices.values())
485            .any(|v| (0..3).all(|k| (v.point[k].to_f64() - top[k]).abs() < 1e-12));
486        assert!(found, "no corner at {top:?}");
487    }
488
489    /// A taper to nothing is refused.
490    #[test]
491    fn refusals_say_why() {
492        let part = block_part("b", [0.0; 3], [1.0, 1.0, 2.0]);
493        let error =
494            deformed(part.clone(), args(&part, Deform::Taper, (0.0, 2.0), 0.0)).unwrap_err();
495        assert!(format!("{error}").contains("positive"), "{error}");
496    }
497}