1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
33pub struct ScaleBody;
34
35#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
36pub struct ScaleBodyArgs {
37 pub bodies: Vec<BodyRef>,
39 #[serde(default)]
41 pub about: [f64; 3],
42 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 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 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 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
142fn 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 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 #[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 #[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 #[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 #[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}