Skip to main content

geop_ops_plastic/
rib.rs

1//! [`rib`]: a thin wall grown from an open sketch profile until it meets a
2//! solid, and joined to it.
3//!
4//! **Its ends.** Each end of the profile runs on along its tangent, in the
5//! sketch's plane, into the wall it points at: to the middle of that wall,
6//! between where the line enters the solid and where it leaves it again —
7//! a place the geometry defines, so the rib's end face lies on no face of
8//! the solid. An end already inside the solid stays where it is; one that
9//! points at nothing of the solid is refused, by name.
10//!
11//! **Its growth.** Normal to the sketch, the profile is thickened in its
12//! plane — on one side, the other, or both alike (see [`crate::band`]) —
13//! and swept along the plane's normal. Parallel to the sketch, the profile
14//! is swept in its plane, square to the chord between its ends, and
15//! thickened along the plane's normal. Either way the sweep reaches well
16//! past the solid, and only what grows from the profile until it runs into
17//! the solid is joined: the same "up to next" an extrude goes by
18//! ([`geop_ops_model::Combine`] with a [`Tool`] going up to the next
19//! face). A profile that is not stopped by the solid all along is refused.
20
21use geop_core_geometry::{
22    intersection::curve_surface_intersect,
23    nurb_curve::{NurbCurve2D, NurbCurve3D},
24};
25use geop_core_math::{
26    geop_error::{GeopError, GeopResult, WithContext},
27    plain,
28    primitives::CoordinateSystem,
29    scalars::Scalar,
30    vector::{Vector2, Vector3},
31    with_context,
32};
33use geop_core_topology::{
34    Body, SolidId,
35    contains::{
36        face::{PointClassification as OnFace, face_contains},
37        shell::{Classified, PointClassification, solid_contains},
38    },
39};
40use geop_lib_booleans::{Reach, combine_tools};
41use geop_lib_sweep::{SweepLoop, extrude};
42use geop_ops_model::{Combine, Profile, Tool, face_hull};
43use geop_ops_model::{NamedModel, Namer, ToolReach};
44
45use geop_core_geometry::search::{MAX_NODES, min_subdivision_size};
46
47use crate::band::{Chain, Piece};
48
49/// Seeds the ray casts' containment queries: any fixed value keeps a rib
50/// reproducible run to run.
51const SEED: u64 = 0x7269_6273;
52
53/// Which way a rib grows from its profile.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum Growth {
56    /// Along the sketch plane's normal — backwards if `flipped`.
57    Normal { flipped: bool },
58    /// In the sketch plane, square to the chord between the profile's ends,
59    /// to its left — to its right if `flipped`.
60    Parallel { flipped: bool },
61}
62
63/// Grows a rib `(near, far)` thick — from `near` to `far` to the left of the
64/// profile in its plane, growing normal to it; from `near` to `far` along
65/// the plane's normal, growing parallel to it — from the open profile
66/// `profile` in `plane`, up to the solid `target`, and joins it, for the
67/// operation `operation_id` whose names `namer` builds: the result is named
68/// `namer`'s root. See the module docs.
69#[allow(clippy::too_many_arguments)]
70pub fn rib(
71    part: &mut NamedModel,
72    namer: &Namer,
73    operation_id: &str,
74    plane: &CoordinateSystem,
75    profile: &Profile,
76    target: SolidId,
77    (near, far): (Scalar, Scalar),
78    growth: Growth,
79) -> GeopResult<()> {
80    let ctx = with_context!(
81        "rib({}, target={target}, thickness=({near:?}, {far:?}), {growth:?})",
82        namer.root()
83    );
84    if !far.sub(near).definitely_greater(Scalar::ZERO) {
85        return Err(GeopError::new("a rib is thicker than zero")).with_context(ctx);
86    }
87    if profile.is_closed() {
88        return Err(GeopError::new(
89            "a rib grows from an open profile: its sketch encloses an area",
90        ))
91        .with_context(ctx);
92    }
93    let target_name = part.name(target).with_context(ctx)?.to_string();
94    let chain = extended(part, plane, profile, target).with_context(ctx)?;
95    let reach = reach_past(part, plane, target).with_context(ctx)?;
96    let tool_name = namer.name(&["tool"]);
97    let (built, start, end) = match growth {
98        Growth::Normal { flipped } => {
99            let loops: Vec<SweepLoop> = chain
100                .band(near, far)
101                .with_context(ctx)?
102                .into_iter()
103                .map(SweepLoop::plain)
104                .collect();
105            let to = if flipped { reach.neg() } else { reach };
106            let built = extrude(
107                part,
108                namer,
109                Some(&tool_name),
110                plane,
111                &[Scalar::ZERO, to],
112                &loops,
113            )
114            .with_context(ctx)?;
115            (built, namer.name(&["start"]), namer.name(&["end"]))
116        }
117        Growth::Parallel { flipped } => {
118            let swept = swept_region(&chain, reach, flipped).with_context(ctx)?;
119            let built = extrude(
120                part,
121                namer,
122                Some(&tool_name),
123                plane,
124                &[near, far],
125                &[SweepLoop::plain(swept)],
126            )
127            .with_context(ctx)?;
128            // Grown from the profile's first piece: the profile lies in
129            // one open space, so the piece it grows from is the one that
130            // piece lies in.
131            (
132                built,
133                namer.name(&[&profile.curve_names[0]]),
134                namer.name(&["far"]),
135            )
136        }
137    };
138    let tool = Tool {
139        solid: built.solid.expect("extruded as a solid"),
140        reach: ToolReach {
141            up_to_next: Some((start, end)),
142            scope: None,
143        },
144    };
145    let combine = Combine::join(target_name);
146    match combine_tools(part, namer, operation_id, &[tool], &combine).with_context(ctx)? {
147        Reach::Stopped(()) => Ok(()),
148        Reach::RunsOn(e) => Err(e.with_context(
149            "the rib's profile grows past the solid without meeting it all along: it has to run between faces that close it in",
150        ))
151        .with_context(ctx),
152    }
153}
154
155/// How far along a direction of `plane`, from its origin, reaches past all
156/// of `target`: its distance to the furthest corner of the box around the
157/// solid's control points, and that box's diagonal again.
158fn reach_past(part: &NamedModel, plane: &CoordinateSystem, target: SolidId) -> GeopResult<Scalar> {
159    let model = part.topology();
160    let hull = face_hull(model, &model.body_faces(Body::Solid(target))?)?;
161    let (lo, hi) = plain::bounds(hull.iter().map(|p| p.to_f64_array()))
162        .ok_or_else(|| GeopError::new(format!("solid {target:?} has no faces")))?;
163    let origin = [0, 1, 2].map(|k| plane.origin()[k].to_f64());
164    let diagonal = plain::diagonal(lo, hi);
165    let furthest = (0..8)
166        .map(|corner: usize| {
167            (0..3)
168                .map(|k| {
169                    let x = if corner >> k & 1 == 1 { hi[k] } else { lo[k] };
170                    (x - origin[k]).powi(2)
171                })
172                .sum::<f64>()
173                .sqrt()
174        })
175        .fold(0.0, f64::max);
176    Ok(Scalar::from_f64(furthest + diagonal))
177}
178
179/// The profile as a [`Chain`], each end run on along its tangent into the
180/// wall of `target` it points at (see the module docs): a straight piece
181/// `ext0` before it, `ext1` after it — or none, for an end inside the
182/// solid already.
183fn extended(
184    part: &NamedModel,
185    plane: &CoordinateSystem,
186    profile: &Profile,
187    target: SolidId,
188) -> GeopResult<Chain> {
189    let lift = |p: &Vector2| plane.uv_to_xyz(p);
190    let mut pieces = Vec::with_capacity(profile.curves.len() + 2);
191    for (curve, name) in profile.curves.iter().zip(&profile.curve_names) {
192        pieces.push(Piece {
193            curve: curve.clone(),
194            center: arc_center(curve)?,
195            name: name.clone(),
196        });
197    }
198    let mut joints = profile.joint_names.clone();
199    let reach = reach_past(part, plane, target)?;
200    let first = &profile.curves[0];
201    let last = profile.curves.last().expect("a profile has curves");
202    let (t0, _) = first.domain();
203    let (_, t1) = last.domain();
204    let start = first.start()?;
205    let end = last.end()?;
206    let back = first.tangent(t0)?.normalize()?.neg();
207    let on = last.tangent(t1)?.normalize()?;
208    let into_wall = |from: Vector2, along: Vector2, joint: &str| {
209        wall_middle(
210            part,
211            target,
212            lift(&from),
213            &plane.uv_direction(&along),
214            reach,
215        )
216        .map_err(|e| e.with_context(format!("running the rib's end {joint} on")))
217    };
218    if let Some(at) = into_wall(start, back, &joints[0])? {
219        let to = plane.to_uv(&at);
220        pieces.insert(
221            0,
222            Piece {
223                curve: NurbCurve2D::line(to, start)?,
224                center: None,
225                name: "ext0".into(),
226            },
227        );
228        joints.insert(0, "end0".into());
229    }
230    let last_joint = joints.last().expect("joints").clone();
231    if let Some(at) = into_wall(end, on, &last_joint)? {
232        let to = plane.to_uv(&at);
233        pieces.push(Piece {
234            curve: NurbCurve2D::line(end, to)?,
235            center: None,
236            name: "ext1".into(),
237        });
238        joints.push("end1".into());
239    }
240    let n = pieces.len();
241    Ok(Chain {
242        pieces,
243        joints,
244        smooth: vec![false; n],
245        closed: false,
246    })
247}
248
249/// The centre of the circle `curve` runs along, if it is an arc.
250fn arc_center(curve: &NurbCurve2D) -> GeopResult<Option<Vector2>> {
251    if curve.degree == 1 {
252        return Ok(None);
253    }
254    let lifted = curve.lifted()?;
255    if lifted.as_line()?.is_some() {
256        return Ok(None);
257    }
258    let arc = lifted.as_arc()?.ok_or_else(|| {
259        GeopError::new("a rib's profile is made of lines and arcs, and this piece is neither")
260    })?;
261    Ok(Some(Vector2::from_array([
262        arc.circle.center[0],
263        arc.circle.center[1],
264    ])))
265}
266
267/// Where a line from `from` along the unit vector `along` should end in the
268/// wall of `target` it runs into: halfway between where it enters the
269/// solid and where it leaves it again — or, from a point on the solid's
270/// boundary, halfway to where it leaves. None for a point inside the solid:
271/// it is in the wall already. `reach` is a length past all of the solid.
272fn wall_middle(
273    part: &NamedModel,
274    target: SolidId,
275    from: Vector3,
276    along: &Vector3,
277    reach: Scalar,
278) -> GeopResult<Option<Vector3>> {
279    let model = part.topology();
280    let start = match solid_contains(
281        model,
282        target,
283        from,
284        MAX_NODES,
285        min_subdivision_size(),
286        SEED,
287        &mut Classified::default(),
288    )? {
289        PointClassification::Inside => return Ok(None),
290        PointClassification::Outside => None,
291        _ => Some(0.0),
292    };
293    let ray = NurbCurve3D::line(from, from.add(&along.prod_scalar(reach)))?;
294    // Where the line crosses the solid's boundary, as fractions of `reach`
295    // — a crossing on an edge once, though both its faces report it.
296    let mut hits: Vec<Scalar> = Vec::new();
297    for face in model.body_faces(Body::Solid(target))? {
298        let surface = &model.get_face(face)?.surface;
299        let found =
300            curve_surface_intersect(&ray, surface, MAX_NODES, MAX_NODES, min_subdivision_size())?;
301        if found.is_coincident() {
302            return Err(GeopError::new(format!(
303                "it runs along face {} of the solid: draw the profile off the solid's faces",
304                part.name_of(face).unwrap_or("?")
305            )));
306        }
307        for (t, uv) in found.into_vec() {
308            if !t.definitely_greater(Scalar::ZERO) {
309                continue;
310            }
311            let class = face_contains(
312                model,
313                face,
314                uv[0].midpoint(),
315                uv[1].midpoint(),
316                MAX_NODES,
317                min_subdivision_size(),
318                SEED ^ face.0,
319            )?;
320            if class != OnFace::Outside && !hits.iter().any(|h| h.could_be_equal(t)) {
321                hits.push(t);
322            }
323        }
324    }
325    hits.sort_by(|a, b| a.to_f64().total_cmp(&b.to_f64()));
326    let (enter, leave) = match (start, hits.as_slice()) {
327        (Some(on), [leave, ..]) => (on, leave.to_f64()),
328        (None, [enter, leave, ..]) => (enter.to_f64(), leave.to_f64()),
329        _ => {
330            return Err(GeopError::new(
331                "it points at nothing of the solid: a rib's profile has to point at the walls it runs between",
332            ));
333        }
334    };
335    // The middle of the wall is a free choice: sharp.
336    let t = Scalar::from_f64((enter + leave) / 2.0);
337    Ok(Some(from.add(&along.prod_scalar(reach.mul(t)))))
338}
339
340/// The region `chain` sweeps in its plane, square to the chord between its
341/// ends, `reach` far — to the chord's left, or its right if `flipped` — as
342/// one counter-clockwise loop: the chain, the side from its end, the far
343/// side `far`, the side back to its start. Every piece has to run forwards
344/// along the chord, or the region would fold over itself.
345fn swept_region(chain: &Chain, reach: Scalar, flipped: bool) -> GeopResult<Profile> {
346    let first = &chain.pieces[0].curve;
347    let last = &chain.pieces.last().expect("pieces").curve;
348    let start = first.start()?;
349    let end = last.end()?;
350    let chord = end.sub(&start).normalize()?;
351    for piece in &chain.pieces {
352        let (t0, t1) = piece.curve.domain();
353        for t in [t0, t1] {
354            if !piece
355                .curve
356                .tangent(t)?
357                .prod_dot(&chord)
358                .definitely_greater(Scalar::ZERO)
359            {
360                return Err(GeopError::new(format!(
361                    "{} turns back against the direction from the profile's start to its end: grown parallel to the sketch, the rib would fold over itself",
362                    piece.name
363                )));
364            }
365        }
366    }
367    let left = chord.perp();
368    let sweep = if flipped { left.neg() } else { left }.prod_scalar(reach);
369    let (end_far, start_far) = (end.add(&sweep), start.add(&sweep));
370    let mut curves: Vec<NurbCurve2D> = chain.pieces.iter().map(|p| p.curve.clone()).collect();
371    curves.push(NurbCurve2D::line(end, end_far)?);
372    curves.push(NurbCurve2D::line(end_far, start_far)?);
373    curves.push(NurbCurve2D::line(start_far, start)?);
374    let mut curve_names: Vec<String> = chain.pieces.iter().map(|p| p.name.clone()).collect();
375    curve_names.extend(["side1".into(), "far".into(), "side0".into()]);
376    let mut joint_names = chain.joints.clone();
377    joint_names.extend(["far1".into(), "far0".into()]);
378    let region = Profile {
379        name: String::new(),
380        curves,
381        curve_names,
382        joint_names,
383    };
384    // Swept to the chord's left, the region lies on the chain's left: the
385    // loop runs counter-clockwise.
386    Ok(if flipped { region.reversed() } else { region })
387}