geop_ops_plastic/draft.rs
1//! [`draft`]: planar faces of a solid tilted about a neutral plane, so the
2//! part comes out of its mould.
3//!
4//! **Why the faces are replaced, not cut.** A draft only changes the
5//! planes some faces lie in; the solid keeps its topology — every face,
6//! edge and vertex is still there, still between the same neighbours. So
7//! the drafted solid is rebuilt face by face ([`geop_lib_shell::rebuilt`]):
8//! each drafted face gets its tilted plane, each vertex of one moves to
9//! where the surfaces around it now meet, each edge to where its two faces
10//! do. The alternative, a boolean with a wedge per face, would have to put
11//! the wedge's tilted face exactly through the face's edge on the neutral
12//! plane — a cut along an edge that already exists, the one configuration
13//! a boolean cannot stay tight through — and a wedge beside an inward
14//! corner would cut into the neighbour. Replacing the geometry needs
15//! neither, and keeps every name.
16//!
17//! **The tilt.** A face with outward normal `n` turns about the line where
18//! its plane meets the neutral plane, by the draft angle, so that its normal
19//! leans towards the pull direction `p`, the neutral plane's normal: past
20//! the neutral plane, along `p`, the solid gets narrower, and it comes out
21//! of a mould pulled that way. A negative angle leans the faces the other
22//! way.
23//!
24//! What it does not do (yet): draft faces that are not planar, faces
25//! parallel to the neutral plane (they have no line to turn about), or
26//! faces that meet a neighbour tangentially (a fillet along an edge). An
27//! angle so large that an edge turns around, or that a wall's two sides
28//! cross, is refused.
29
30use geop_core_geometry::shape::{Axis, Plane};
31use geop_core_math::{
32 geop_error::{GeopError, GeopResult, WithContext},
33 scalars::Scalar,
34 vector::{Vector3, Vector4},
35 with_context,
36};
37use geop_core_topology::{FaceId, SolidId};
38use geop_lib_shell::{Smooth, replace_faces};
39use geop_ops_model::{NamedModel, Namer};
40
41/// Tilts the planar faces `faces` of one solid by `angle` (in radians, a
42/// quarter turn either way at most) about where each meets the plane
43/// `neutral`, whose normal is the pull direction — see the module docs —
44/// and returns the drafted solid, which replaces it and is named
45/// `namer`'s root. Every face, edge and vertex keeps its name.
46pub fn draft(
47 part: &mut NamedModel,
48 namer: &Namer,
49 faces: &[FaceId],
50 neutral: &Plane,
51 angle: Scalar,
52) -> GeopResult<SolidId> {
53 let ctx = with_context!(
54 "draft({}, faces={faces:?}, neutral={neutral:?}, angle={angle:?})",
55 namer.root()
56 );
57 let quarter = Scalar::from_f64(std::f64::consts::FRAC_PI_2);
58 if !(angle.abs().definitely_greater(Scalar::ZERO) && angle.abs().definitely_less(quarter)) {
59 return Err(GeopError::new(format!(
60 "a draft angle is more than zero and less than a quarter turn, not {:?} degrees",
61 angle.to_f64().to_degrees()
62 )))
63 .with_context(ctx);
64 }
65 let tilt = Tilt {
66 neutral,
67 cos: angle.cos(),
68 sin: angle.sin(),
69 };
70 replace_faces(part, namer, faces, Smooth::Refuse, |name, surface| {
71 let plane = surface.as_plane()?.ok_or_else(|| {
72 GeopError::new(format!(
73 "face {name} is not planar: only planar faces can be drafted"
74 ))
75 })?;
76 let hinge = tilt.hinge(&plane).map_err(|e| {
77 e.with_context(format!(
78 "face {name} is parallel to the neutral plane: it has no line to be drafted about"
79 ))
80 })?;
81 let mut turned = surface.clone();
82 for cp in &mut turned.control_points {
83 *cp = tilt.turn(&hinge, cp);
84 }
85 turned.recompute_aabb();
86 Ok(turned)
87 })
88 .with_context(ctx)
89}
90
91/// How a drafted face turns: about where its plane meets `neutral`, by the
92/// angle whose cosine and sine these are.
93struct Tilt<'a> {
94 neutral: &'a Plane,
95 cos: Scalar,
96 sin: Scalar,
97}
98
99impl Tilt<'_> {
100 /// The line the face in `plane` turns about, along `n x p` — turning
101 /// about it by a positive angle leans `n` towards `p`.
102 fn hinge(&self, plane: &Plane) -> GeopResult<Axis> {
103 plane.intersect_plane(self.neutral)
104 }
105
106 /// `p` turned about `hinge`, homogeneous: `(w x, w)` to `(w R(x - c) +
107 /// w c, w)`, which needs no division by the weight.
108 fn turn(&self, hinge: &Axis, p: &Vector4) -> Vector4 {
109 let w = p[3];
110 let c = hinge.point.prod_scalar(w);
111 let v = Vector3::from_array([p[0], p[1], p[2]]).sub(&c);
112 let turned = v
113 .rotated_about(&hinge.direction, self.cos, self.sin)
114 .add(&c);
115 Vector4::from_array([turned[0], turned[1], turned[2], w])
116 }
117}