Skip to main content

geop_ops_pattern/
scale_body.rs

1//! [`ScaleBody`]: bodies scaled about a point, by one factor or by one
2//! along each axis.
3
4use geop_core_editor::{
5    edit_form::{EditForm, FormState},
6    editable::{Context, OperationGroup, OperationInfo, OperationTier},
7    editing::{Number, Unit},
8    part_operation,
9    part_operation::PartOperation,
10};
11use geop_core_math::{
12    geop_error::{GeopError, GeopResult},
13    primitives::Motion,
14    scalars::Scalar,
15    vector::Vector3,
16};
17use geop_core_program::{part::StepContext, reader::Reader};
18use geop_core_topology::validation::{ValidationParameters, numerical_accuracy};
19use geop_ops_model::{ModelOperation, NamedModel, build_model, read_model, resolve_bodies};
20use serde::{Deserialize, Serialize};
21
22use crate::common::{BodyRef, bodies_field, body};
23
24/// Scales the bodies `bodies` — solids, and sheets by one of their faces —
25/// about the point `about` by `factors` along the world's `x`, `y` and
26/// `z`: in place, every entity keeping its name. Exact: a NURBS curve or
27/// surface scaled is its control points scaled, weights and parametrization
28/// kept, so every pcurve stays as it is. Unequal factors make a circle an
29/// ellipse — exactly, as a NURBS. A negative factor mirrors (an odd number
30/// of them turns every face around, so the normals still point out); a
31/// factor of zero is refused.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
33pub struct ScaleBody;
34
35#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
36pub struct ScaleBodyArgs {
37    /// The bodies to scale: solids, or a face of each sheet.
38    pub bodies: Vec<BodyRef>,
39    /// The point that stays where it is.
40    #[serde(default)]
41    pub about: [f64; 3],
42    /// The factor along the world's `x`, `y` and `z`.
43    pub factors: [f64; 3],
44}
45
46part_operation!(ScaleBody(ScaleBodyArgs));
47
48impl PartOperation for ScaleBody {
49    const KIND: &'static str = "scale_body";
50    const INFO: OperationInfo = OperationInfo::new(
51        "Scale body",
52        "Scale bodies about a point, by one factor or by one along each axis.",
53        OperationGroup::Bodies,
54        OperationTier::Menu,
55    )
56    .icon(crate::icons::SCALE_BODY);
57    type Args = ScaleBodyArgs;
58    type Session = ();
59
60    /// The newest solid, about the origin, not scaled yet.
61    fn new_args(context: &Context) -> ScaleBodyArgs {
62        ScaleBodyArgs {
63            bodies: (read_model(&context.before).ok())
64                .and_then(|model| model.newest_solid())
65                .map(body)
66                .into_iter()
67                .collect(),
68            about: [0.0; 3],
69            factors: [1.0; 3],
70        }
71    }
72
73    fn run(ctx: &mut StepContext, args: &ScaleBodyArgs) -> GeopResult<()> {
74        let _step = ctx.step();
75        build_model::<Self>(ctx, args)
76    }
77
78    /// The bodies, picked; the point scaled about and the factors.
79    fn form<'a>(
80        context: &'a Context<'a>,
81        args: &ScaleBodyArgs,
82        _: &FormState<()>,
83    ) -> EditForm<'a, ScaleBodyArgs> {
84        let mut f = EditForm::<ScaleBodyArgs>::default();
85        bodies_field(&mut f, &context.before, &args.bodies, |edit, picked| {
86            edit.args.bodies = picked
87        });
88        for (k, axis) in ["x", "y", "z"].into_iter().enumerate() {
89            let label = ["about x", "about y", "about z"][k];
90            let number = Number::new(label, args.about[k], Unit::Length);
91            f.number(
92                ["about_x", "about_y", "about_z"][k],
93                number,
94                move |edit, v| edit.args.about[k] = v,
95            );
96            let label = ["factor x", "factor y", "factor z"][k];
97            let number = Number::new(label, args.factors[k], Unit::Fraction);
98            f.number(axis, number, move |edit, v| edit.args.factors[k] = v);
99        }
100        f
101    }
102}
103
104impl ModelOperation for ScaleBody {
105    fn build(
106        model: &mut NamedModel,
107        reader: &Reader,
108        _: &str,
109        args: &ScaleBodyArgs,
110    ) -> GeopResult<()> {
111        let values = args.about.iter().chain(&args.factors);
112        if let Some(bad) = values.clone().find(|v| !v.is_finite()) {
113            return Err(GeopError::new(format!("{bad} is not a number")));
114        }
115        let about = Vector3::from_array(args.about.map(Scalar::from_f64));
116        let motion = Motion::scaling(&about, args.factors.map(Scalar::from_f64))?;
117        let before = imprecise(model);
118        for body in resolve_bodies(reader, &args.bodies)? {
119            model.transform_body(body, &motion)?;
120        }
121        // Scaled, an entity's enclosure scales with it, and the kernel's
122        // budget of what an entity may carry and of the shortest edge it
123        // resolves does not: what scaling makes too wide or too short is
124        // refused, by name, not left to fail in the next step.
125        let made: Vec<String> = (imprecise(model).into_iter())
126            .filter(|error| !before.contains(error))
127            .collect();
128        if let Some(first) = made.first() {
129            return Err(GeopError::new(format!(
130                "scaled by {:?}, the bodies are not as precise as the kernel needs ({} entities, \
131                 {}): {first} — scale what they were built from instead, before the features \
132                 that trace curves on them, or scale by a factor nearer 1",
133                args.factors,
134                made.len(),
135                model.names_mentioned(first).join(", "),
136            )));
137        }
138        Ok(())
139    }
140}
141
142/// What of `model` is wider than an entity may carry, or an edge shorter
143/// than the searches resolve, as validation says it (see
144/// [`geop_core_topology::validation::numerical_accuracy`]).
145fn imprecise(model: &NamedModel) -> Vec<String> {
146    let mut errors = Vec::new();
147    numerical_accuracy::check_numerical_accuracy(
148        &ValidationParameters::default(),
149        &mut errors,
150        model.topology(),
151    );
152    (errors.iter())
153        .map(|e| e.root_message().to_string())
154        .collect()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use geop_lib_sweep::shapes::{block_part, cylinder};
161    use geop_ops_model::testing::{apply_to_model, assert_valid};
162
163    /// The volume of the solid `solid` of `part`, integrated from its
164    /// faces (see `geop_core_topology::mass`).
165    fn solid_volume(part: &NamedModel, solid: &str) -> f64 {
166        let solid = part.solid_id(solid).unwrap();
167        (part.topology().mass_properties(solid).unwrap().volume).to_f64()
168    }
169
170    fn scaled(part: NamedModel, factors: [f64; 3]) -> GeopResult<NamedModel> {
171        let name = part.solid_names()[0].clone();
172        let args = ScaleBodyArgs {
173            bodies: vec![body(name)],
174            about: [1.0, 1.0, 1.0],
175            factors,
176        };
177        apply_to_model::<ScaleBody>(part, "s", &args)
178    }
179
180    /// A box scaled by 2, 3 and 0.5: three times the volume,
181    /// valid, the corner it was scaled about where it was.
182    #[test]
183    fn a_box_scaled_unevenly() {
184        let part = block_part("b", [0.0; 3], [2.0, 2.0, 2.0]);
185        let part = scaled(part, [2.0, 3.0, 0.5]).unwrap();
186        assert_valid(&part);
187        let solid = part.solid_names()[0].clone();
188        assert!((solid_volume(&part, &solid) - 24.0).abs() < 1e-9);
189    }
190
191    /// A cylinder scaled along `x` only: an elliptic cylinder, exact.
192    #[test]
193    fn a_cylinder_scaled_into_an_ellipse() {
194        let mut part = NamedModel::new();
195        cylinder(&mut part, "c", [0.0; 3], 1.0, 1.0);
196        let part = scaled(part, [2.0, 1.0, 1.0]).unwrap();
197        assert_valid(&part);
198        let solid = part.solid_names()[0].clone();
199        let volume = 2.0 * std::f64::consts::PI;
200        assert!((solid_volume(&part, &solid) - volume).abs() < 1e-9);
201    }
202
203    /// A negative factor mirrors: the box is still valid, its faces turned
204    /// to point out.
205    #[test]
206    fn a_negative_factor_mirrors() {
207        let part = block_part("b", [0.0; 3], [2.0, 2.0, 2.0]);
208        let part = scaled(part, [-1.0, 1.0, 1.0]).unwrap();
209        assert_valid(&part);
210    }
211
212    /// A factor of zero is refused.
213    #[test]
214    fn a_zero_factor_is_refused() {
215        let part = block_part("b", [0.0; 3], [2.0, 2.0, 2.0]);
216        let error = scaled(part, [1.0, 0.0, 1.0]).unwrap_err();
217        assert!(format!("{error}").contains("flattens"), "{error}");
218    }
219}