1use geop_core_editor::{
5 edit_form::{EditForm, FormState},
6 editable::{Context, OperationGroup, OperationInfo, OperationTier},
7 editing::{Choice, Number, Unit},
8 part_operation,
9 part_operation::PartOperation,
10};
11use geop_core_math::{
12 geop_error::{GeopError, GeopResult, WithContext},
13 scalars::Scalar,
14 vector::{Vector2, Vector3},
15 with_context,
16};
17use geop_core_program::{numeric::NumericInput, part::StepContext, reader::Reader};
18use geop_core_sketch::{CurveKind, ProfileLoop, Shape};
19use geop_ops_model::sketch::{PlacedSketch, SketchRef, shape_profiles};
20use geop_ops_model::{ModelOperation, NamedModel, Namer, Profile};
21use serde::{Deserialize, Serialize};
22
23use crate::{
24 body::{self, Made, Making, SheetStep},
25 sheet::{Bend, BendFrame, Flat, FlatEdge, Placement, Relief, Sheet, SheetMetalRules, straight},
26};
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
56pub struct BaseFlange;
57
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct BaseFlangeArgs {
60 pub sketch: SketchRef,
62 pub rules: SheetMetalRules,
63 pub depth: NumericInput,
66 #[serde(default)]
69 pub flip: bool,
70}
71
72impl SheetStep for BaseFlange {
73 fn plan(reader: &Reader, step: &str, args: &BaseFlangeArgs) -> GeopResult<Made> {
74 let ctx = with_context!("base_flange({step}, {args:?})");
75 let namer = Namer::new("base_flange", step)?;
76 args.rules.check().with_context(ctx)?;
77 let placed = reader.retrieve_element(&args.sketch).with_context(ctx)?;
78 let sheet = match placed.sketch.shape().with_context(ctx)? {
79 Shape::Regions(regions) if regions.len() == 1 => {
80 plate(&namer, placed, Shape::Regions(regions), args)
81 }
82 Shape::Regions(regions) => Err(GeopError::new(format!(
83 "the sketch has {} areas apart, and a base flange is one sheet: draw each in a sketch of its own",
84 regions.len()
85 ))),
86 Shape::Chain(chain) => {
87 let depth = args.depth.resolve(reader).with_context(ctx)?;
88 strip(&namer, placed, &chain, depth, args)
89 }
90 }
91 .with_context(ctx)?;
92 Ok(Made {
93 consumes: None,
94 solid: namer.root(),
95 making: Making::Bent(sheet),
96 })
97 }
98}
99
100part_operation!(BaseFlange(BaseFlangeArgs));
101
102impl PartOperation for BaseFlange {
103 const KIND: &'static str = "base_flange";
104 const INFO: OperationInfo = OperationInfo::new(
105 "Base flange",
106 "Start a sheet-metal body from a sketch: a plate from its area, or a bent strip from a chain of lines and arcs.",
107 OperationGroup::SheetMetal,
108 OperationTier::Big,
109 )
110 .icon(crate::icons::BASE_FLANGE);
111 type Args = BaseFlangeArgs;
112 type Session = ();
113
114 fn new_args(context: &Context) -> BaseFlangeArgs {
116 let newest = context.before.targets_of::<PlacedSketch>().pop();
117 BaseFlangeArgs {
118 sketch: SketchRef::whole(newest.unwrap_or_else(|| geop_ops_model::sketch::sketch(""))),
119 rules: SheetMetalRules::default(),
120 depth: NumericInput::Plain(1.0),
121 flip: false,
122 }
123 }
124
125 fn run(ctx: &mut StepContext, args: &BaseFlangeArgs) -> GeopResult<()> {
126 let _step = ctx.step();
127 body::run::<Self>(ctx, args)
128 }
129
130 fn form<'a>(
133 context: &'a Context<'a>,
134 args: &BaseFlangeArgs,
135 _: &FormState<()>,
136 ) -> EditForm<'a, BaseFlangeArgs> {
137 let before = &context.before;
138 let mut f = EditForm::<BaseFlangeArgs>::default();
139 f.pick_one(
140 "sketch",
141 "sketch",
142 before,
143 Some(&args.sketch),
144 |edit, sketch| edit.args.sketch = sketch,
145 );
146 let chain = (before.retrieve_element(&args.sketch))
147 .and_then(|placed| placed.sketch.shape())
148 .is_ok_and(|shape| matches!(shape, Shape::Chain(_)));
149 if chain {
150 let depth = Number::numeric(&args.depth, "depth", before, Unit::Length);
151 f.numeric("depth", depth.range(0.0, 10.0), |edit, depth| {
152 edit.args.depth = depth
153 });
154 }
155 f.checkbox("flip", "flip side", args.flip, |edit, b| edit.args.flip = b);
156 rules_fields(&mut f, &args.rules);
157 f
158 }
159}
160
161impl ModelOperation for BaseFlange {
162 fn build(
163 model: &mut NamedModel,
164 reader: &Reader,
165 step: &str,
166 _: &BaseFlangeArgs,
167 ) -> GeopResult<()> {
168 body::build(model, reader, step)
169 }
170}
171
172fn rules_fields(f: &mut EditForm<'_, BaseFlangeArgs>, rules: &SheetMetalRules) {
174 let thickness = Number::new("thickness", rules.thickness, Unit::Length).range(0.0, 1.0);
175 f.number("thickness", thickness, |edit, t| {
176 edit.args.rules.thickness = t
177 });
178 let radius = Number::new("bend radius", rules.bend_radius, Unit::Length).range(0.0, 1.0);
179 f.number("bend_radius", radius, |edit, r| {
180 edit.args.rules.bend_radius = r
181 });
182 let k_factor = Number::new("K-factor", rules.k_factor, Unit::Fraction).range(0.0, 1.0);
183 f.number("k_factor", k_factor, |edit, k| edit.args.rules.k_factor = k);
184 f.select(
185 "relief",
186 "relief",
187 match rules.relief {
188 Relief::Rectangular => "rectangular",
189 Relief::Tear => "tear",
190 },
191 vec![
192 Choice::new("rectangular", "Rectangular"),
193 Choice::new("tear", "Tear"),
194 ],
195 false,
196 |edit, choice| {
197 edit.args.rules.relief = match choice {
198 "tear" => Relief::Tear,
199 _ => Relief::Rectangular,
200 }
201 },
202 );
203 if rules.relief == Relief::Rectangular {
204 let ratio = Number::new("relief ratio", rules.relief_ratio, Unit::Fraction).range(0.0, 2.0);
205 f.number("relief_ratio", ratio, |edit, r| {
206 edit.args.rules.relief_ratio = r
207 });
208 }
209 let gap = Number::new("corner gap", rules.corner_gap, Unit::Length).range(0.0, 1.0);
210 f.number("corner_gap", gap, |edit, g| edit.args.rules.corner_gap = g);
211}
212
213fn plate(
217 namer: &Namer,
218 placed: &PlacedSketch,
219 region: Shape,
220 args: &BaseFlangeArgs,
221) -> GeopResult<Sheet> {
222 let sketch = &placed.sketch;
223 let geometry = sketch.enclose()?;
224 let loops = shape_profiles(&placed.name, sketch, &geometry, region)?;
225 let plane = &placed.plane;
226 let n = *plane.w();
227 let origin = if args.flip {
228 plane
229 .origin()
230 .sub(&n.prod_scalar(Scalar::from_f64(args.rules.thickness)))
231 } else {
232 *plane.origin()
233 };
234 let place = Placement {
235 origin,
236 e1: *plane.u(),
237 e2: *plane.v(),
238 n,
239 };
240 let edges = |lp: &Profile| -> Vec<FlatEdge> {
241 lp.curves
242 .iter()
243 .zip(&lp.curve_names)
244 .map(|(curve, name)| FlatEdge {
245 curve: curve.clone(),
246 name: namer.scoped(name),
247 })
248 .collect()
249 };
250 let mut loops = loops.iter().map(edges);
251 let outer = loops
252 .next()
253 .ok_or_else(|| GeopError::new("the sketch has no area"))?;
254 Ok(Sheet {
255 rules: args.rules.clone(),
256 flats: vec![Flat {
257 place,
258 outer,
259 holes: loops.collect(),
260 name: namer.scoped("plate"),
261 }],
262 bends: Vec::new(),
263 cuts: Vec::new(),
264 })
265}
266
267struct Piece {
271 a: Vector2,
272 b: Vector2,
273 sweep: Option<Scalar>,
274 name: String,
275 start: String,
276 end: String,
277}
278
279impl Piece {
280 fn reversed(self) -> Self {
281 Self {
282 a: self.b,
283 b: self.a,
284 sweep: self.sweep.map(Scalar::neg),
285 name: self.name,
286 start: self.end,
287 end: self.start,
288 }
289 }
290}
291
292struct Turn {
295 name: String,
296 angle: Scalar,
297 radius: Scalar,
298 toward_b: bool,
299 setback: Scalar,
301}
302
303fn strip(
306 namer: &Namer,
307 placed: &PlacedSketch,
308 chain: &ProfileLoop,
309 depth: f64,
310 args: &BaseFlangeArgs,
311) -> GeopResult<Sheet> {
312 let sketch = &placed.sketch;
313 let geometry = sketch.enclose()?;
314 let mut pieces = Vec::new();
315 for edge in &chain.edges {
316 let curve = &sketch.curves[&edge.curve];
317 let point = |id| {
318 geometry
319 .points
320 .get(&id)
321 .copied()
322 .ok_or_else(|| GeopError::new(format!("the sketch has no point {id}")))
323 };
324 let (a, b, sweep) = match curve.kind {
325 CurveKind::Line { start, end } => (start, end, None),
326 CurveKind::Arc { start, end, .. } => (
327 start,
328 end,
329 Some(geometry.params.get(&edge.curve).copied().ok_or_else(|| {
330 GeopError::new(format!("the sketch has no sweep for arc {}", edge.curve))
331 })?),
332 ),
333 _ => {
334 return Err(GeopError::new(format!(
335 "{} is neither a line nor an arc: a sheet-metal strip is drawn with lines, and arcs for its bends",
336 edge.curve
337 )));
338 }
339 };
340 let piece = Piece {
341 a: point(a)?,
342 b: point(b)?,
343 sweep,
344 name: edge.curve.to_string(),
345 start: a.to_string(),
346 end: b.to_string(),
347 };
348 pieces.push(if edge.reversed {
349 piece.reversed()
350 } else {
351 piece
352 });
353 }
354 if args.flip {
355 pieces = pieces.into_iter().rev().map(Piece::reversed).collect();
356 }
357 if !(depth.is_finite() && depth > 0.0) {
358 return Err(GeopError::new(format!(
359 "the strip's depth must be positive, not {depth}"
360 )));
361 }
362
363 let plane = &placed.plane;
364 let w = *plane.w();
365 let t = Scalar::from_f64(args.rules.thickness);
366 let direction =
367 |p: &Piece| -> GeopResult<Vector3> { plane.uv_direction(&p.b.sub(&p.a)).normalize() };
368 let mut lines: Vec<&Piece> = Vec::new();
370 let mut turns: Vec<Turn> = Vec::new();
371 let mut arc: Option<&Piece> = None;
372 for piece in &pieces {
373 if piece.sweep.is_none() {
374 if let Some(&last) = lines.last() {
375 turns.push(turn(
376 last,
377 piece,
378 arc.take(),
379 &direction,
380 &w,
381 t,
382 &args.rules,
383 )?);
384 }
385 lines.push(piece);
386 continue;
387 }
388 if lines.is_empty() || arc.is_some() {
389 return Err(GeopError::new(format!(
390 "arc {} does not lie between two lines: a sheet-metal strip starts and ends flat, and bends from one line to the next",
391 piece.name
392 )));
393 }
394 arc = Some(piece);
395 }
396 if let Some(arc) = arc {
397 return Err(GeopError::new(format!(
398 "the strip ends in arc {}: a sheet-metal strip starts and ends flat",
399 arc.name
400 )));
401 }
402
403 let depth = Scalar::from_f64(depth);
404 let sketch_namer = namer.scoped(&placed.name);
405 let mut flats: Vec<Flat> = Vec::new();
406 let mut bends: Vec<Bend> = Vec::new();
407 let mut x0 = Scalar::ZERO;
408 let mut place = {
409 let e1 = direction(lines[0])?;
410 Placement {
411 origin: plane.uv_to_xyz(&lines[0].a),
412 n: e1.prod_cross(&w),
413 e1,
414 e2: w,
415 }
416 };
417 for (i, line) in lines.iter().enumerate() {
418 let before = i.checked_sub(1).map_or(Scalar::ZERO, |j| turns[j].setback);
419 let after = turns.get(i).map_or(Scalar::ZERO, |turn| turn.setback);
420 let length = line.b.sub(&line.a).norm().sub(before).sub(after);
421 if !length.definitely_greater(Scalar::ZERO) {
422 return Err(GeopError::new(format!(
423 "line {} is too short for its bends: {:?} of it is left flat",
424 line.name, length
425 )));
426 }
427 let x1 = x0.add(length);
428 let p = |x: Scalar, y: Scalar| Vector2::from_array([x, y]);
429 let flat = sketch_namer.scoped(&line.name);
430 let outer = vec![
431 straight(
432 p(x0, Scalar::ZERO),
433 p(x1, Scalar::ZERO),
434 flat.scoped("start"),
435 )?,
436 straight(p(x1, Scalar::ZERO), p(x1, depth), flat.scoped(&line.end))?,
437 straight(p(x1, depth), p(x0, depth), flat.scoped("end"))?,
438 straight(p(x0, depth), p(x0, Scalar::ZERO), flat.scoped(&line.start))?,
439 ];
440 flats.push(Flat {
441 place: place.clone(),
442 outer,
443 holes: Vec::new(),
444 name: flat.clone(),
445 });
446 if let Some(turn) = turns.get(i) {
447 let frame = BendFrame::new(
448 &place,
449 [p(x1, Scalar::ZERO), p(x1, depth)],
450 turn.angle,
451 turn.radius,
452 t,
453 turn.toward_b,
454 )?;
455 let next = sketch_namer.scoped(&lines[i + 1].name);
456 bends.push(Bend {
457 parent: i,
458 parent_edge: flat.scoped(&line.end).root(),
459 child: i + 1,
460 child_edge: next.scoped(&lines[i + 1].start).root(),
461 angle: turn.angle,
462 radius: turn.radius,
463 toward_b: turn.toward_b,
464 name: sketch_namer.scoped(&turn.name),
465 });
466 place = frame.child_placement(&place);
467 }
468 x0 = x1;
469 }
470 Ok(Sheet {
471 rules: args.rules.clone(),
472 flats,
473 bends,
474 cuts: Vec::new(),
475 })
476}
477
478fn turn(
482 from: &Piece,
483 to: &Piece,
484 arc: Option<&Piece>,
485 direction: &dyn Fn(&Piece) -> GeopResult<Vector3>,
486 w: &Vector3,
487 t: Scalar,
488 rules: &SheetMetalRules,
489) -> GeopResult<Turn> {
490 let (d0, d1) = (direction(from)?, direction(to)?);
491 let n = d0.prod_cross(w);
492 let side = d1.prod_dot(&n);
493 let toward_b = if side.definitely_greater(Scalar::ZERO) {
494 true
495 } else if side.definitely_less(Scalar::ZERO) {
496 false
497 } else {
498 return Err(GeopError::new(format!(
499 "lines {} and {} could run straight on: join them into one line, or draw a corner between them",
500 from.name, to.name
501 )));
502 };
503 let cos = d0.prod_dot(&d1);
504 if !cos.definitely_greater(Scalar::ONE.neg()) {
505 return Err(GeopError::new(format!(
506 "line {} could fold straight back onto line {}: a bend turns by less than 180 degrees",
507 to.name, from.name
508 )));
509 }
510 let Some(arc) = arc else {
511 let angle = cos.acos()?;
512 let radius = Scalar::from_f64(rules.bend_radius);
513 let outer = if toward_b { radius.add(t) } else { radius };
514 let half = angle.div(Scalar::TWO)?;
515 return Ok(Turn {
516 name: from.end.clone(),
517 angle,
518 radius,
519 toward_b,
520 setback: outer.mul(half.sin().div(half.cos())?),
521 });
522 };
523 let sweep = arc.sweep.expect("an arc has a sweep");
526 let angle = sweep.abs();
527 let half = sweep.div(Scalar::TWO)?;
528 let chord = arc.b.sub(&arc.a);
529 let r_a = chord.norm().div(Scalar::TWO.mul(half.sin().abs()))?;
530 let chord = chord.normalize()?;
531 let turned = |by: Scalar| {
532 let (c, s) = (by.cos(), by.sin());
533 Vector2::from_array([
534 chord[0].mul(c).sub(chord[1].mul(s)),
535 chord[0].mul(s).add(chord[1].mul(c)),
536 ])
537 };
538 let along = |d: Vector2, line: &Piece| -> GeopResult<bool> {
539 let l = line.b.sub(&line.a).normalize()?;
540 Ok(d.prod_cross(&l).could_be_equal(Scalar::ZERO)
541 && d.prod_dot(&l).definitely_greater(Scalar::ZERO))
542 };
543 if !(from.b.could_be_equal(&arc.a) && arc.b.could_be_equal(&to.a)) {
544 return Err(GeopError::new(format!(
545 "arc {} does not join lines {} and {}",
546 arc.name, from.name, to.name
547 )));
548 }
549 if !(along(turned(half.neg()), from)? && along(turned(half), to)?) {
550 return Err(GeopError::new(format!(
551 "arc {} is not tangent to lines {} and {}: a bend runs on smoothly from one flat to the next",
552 arc.name, from.name, to.name
553 )));
554 }
555 let radius = if toward_b { r_a.sub(t) } else { r_a };
556 if !radius.definitely_greater(Scalar::ZERO) {
557 return Err(GeopError::new(format!(
558 "arc {} has a radius of {:?}, no more than the thickness on the inside of its bend",
559 arc.name, r_a
560 )));
561 }
562 Ok(Turn {
563 name: arc.name.clone(),
564 angle,
565 radius,
566 toward_b,
567 setback: Scalar::ZERO,
568 })
569}