Skip to main content

geop_ops_boolean/
split_face.rs

1//! [`SplitFace`]: divide a face along a sketch's curves, projected onto it,
2//! the solid otherwise unchanged.
3
4use geop_core_editor::{
5    edit_form::{EditForm, FormState, choices, chosen},
6    editable::{Context, OperationGroup, OperationInfo, OperationTier},
7    part_operation,
8    part_operation::PartOperation,
9};
10use geop_core_geometry::{
11    nurb_curve::NurbCurve3D, nurb_surface::NurbSurface3D, search::PROJECT_ITERATIONS,
12};
13use geop_core_math::{
14    geop_error::{GeopError, GeopResult, WithContext},
15    plain,
16    primitives::CoordinateSystem,
17    scalars::Scalar,
18    vector::{Vector3, Vector4},
19    with_context,
20};
21use geop_core_program::{element::ElementReference, part::StepContext, reader::Reader};
22use geop_core_topology::{
23    Body, FaceId, Sense, ShellId,
24    build::{BodySpec, CoedgeOn, EdgeSpec, FaceSpec},
25};
26use geop_lib_booleans::{RemeshParams, imprint_on_face};
27use geop_lib_sweep::{SweepLoop, extrude};
28use geop_ops_model::sketch::{PlacedSketch, SketchRef, sketch};
29use geop_ops_model::{
30    BodyNames, Chain, Face, ModelOperation, NamedModel, Namer, build_model, face_hull, spread_along,
31};
32use serde::{Deserialize, Serialize};
33
34/// Divides the face `face` along the curves of the sketch `sketch`,
35/// projected onto it: the one face becomes two or more on the same surface,
36/// and the solid keeps its shape. Every curve of the sketch that is no
37/// construction curve divides: its lines, arcs, circles and splines, however
38/// they join or cross, and the outlines of its text.
39///
40/// Projected [`Along::SketchNormal`], a curve's projection is where its
41/// sweep, reaching past the face both ways, crosses the face: on a face that
42/// bends back over itself, every place it does, and only within the face, so
43/// a curve reaching past it is trimmed to it. Projected [`Along::FaceNormal`],
44/// each point goes to the point of the face's surface nearest it (see
45/// [`normal_sheets`]).
46///
47/// Named as [`imprint_on_face`] names what it creates, with `N` being
48/// `split_face(S)` for the step `S`: the edge along the piece `X` of a curve
49/// of the sketch `K` is `N(K,X)`, or `N(K,X,k)` for its `k`-th stretch across
50/// the face; a piece split off the face `F` along it is `N(F,K,X)` or
51/// `N(F,K,X,k)`, and the other piece keeps the name `F`, as what is left
52/// around a curve closing inside the face does. The face's boundary edges,
53/// split where a curve ends on them, are named as a boolean names an edge
54/// split in two.
55///
56/// Refuses a sketch with no curve to divide along, one that misses the face,
57/// a curve ending inside the face, a curve only joining two of the face's
58/// boundaries, each of which divides nothing, and a curve running along an
59/// edge of the face, which already bounds it.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
61pub struct SplitFace;
62
63#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
64pub struct SplitFaceArgs {
65    /// The face to divide; a new step waits for it to be picked.
66    pub face: Option<ElementReference<NamedModel, Face>>,
67    /// The sketch whose curves to divide it along.
68    pub sketch: SketchRef,
69    /// Which way they are projected onto it.
70    #[serde(default, skip_serializing_if = "Along::is_sketch_normal")]
71    pub along: Along,
72}
73
74/// Which way [`SplitFace`] projects a sketch's curves onto a face.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum Along {
78    /// Along the sketch plane's normal, as an extrude sweeps them.
79    #[default]
80    SketchNormal,
81    /// Each point to the point of the face's surface nearest it: along the
82    /// face's normal there.
83    FaceNormal,
84}
85
86impl Along {
87    fn is_sketch_normal(&self) -> bool {
88        *self == Along::SketchNormal
89    }
90
91    /// Each way, as the dialog offers it: `(way, value, label)`.
92    const OPTIONS: [(Along, &'static str, &'static str); 2] = [
93        (Along::SketchNormal, "sketch_normal", "the sketch's normal"),
94        (Along::FaceNormal, "face_normal", "the face's normal"),
95    ];
96}
97
98part_operation!(SplitFace(SplitFaceArgs));
99
100impl PartOperation for SplitFace {
101    const KIND: &'static str = "split_face";
102    const INFO: OperationInfo = OperationInfo::new(
103        "Split face",
104        "Divide a face along a sketch's curves, projected onto it, leaving the solid's shape as it is.",
105        OperationGroup::Bodies,
106        OperationTier::Menu,
107    )
108    .icon(crate::icons::SPLIT_FACE);
109    type Args = SplitFaceArgs;
110    type Session = ();
111
112    /// No face yet, which a click picks, and the newest sketch.
113    fn new_args(context: &Context) -> SplitFaceArgs {
114        let newest = context.before.targets_of::<PlacedSketch>().pop();
115        SplitFaceArgs {
116            face: None,
117            sketch: SketchRef::whole(newest.unwrap_or_else(|| sketch(""))),
118            along: Along::SketchNormal,
119        }
120    }
121
122    fn run(ctx: &mut StepContext, args: &SplitFaceArgs) -> GeopResult<()> {
123        let _step = ctx.step();
124        build_model::<Self>(ctx, args)
125    }
126
127    /// The face and the sketch, picked, and which way to project.
128    fn form<'a>(
129        context: &'a Context<'a>,
130        args: &SplitFaceArgs,
131        _: &FormState<()>,
132    ) -> EditForm<'a, SplitFaceArgs> {
133        let before = &context.before;
134        let mut f = EditForm::<SplitFaceArgs>::default();
135        f.pick_one("face", "face", before, args.face.as_ref(), |edit, face| {
136            edit.args.face = Some(face)
137        })
138        .pick_one(
139            "sketch",
140            "sketch",
141            before,
142            Some(&args.sketch),
143            |edit, sketch| edit.args.sketch = sketch,
144        );
145        let (way, options) = choices(&Along::OPTIONS, &args.along);
146        f.select("along", "along", way, options, false, |edit, way| {
147            if let Some(along) = chosen(&Along::OPTIONS, way) {
148                edit.args.along = along;
149            }
150        });
151        f
152    }
153}
154
155impl ModelOperation for SplitFace {
156    fn build(
157        model: &mut NamedModel,
158        reader: &Reader,
159        step: &str,
160        args: &SplitFaceArgs,
161    ) -> GeopResult<()> {
162        let namer = Namer::new("split_face", step)?;
163        let Some(face_ref) = &args.face else {
164            return Err(GeopError::new("pick the face to divide").concerning("face"));
165        };
166        let placed = reader.retrieve_element(&args.sketch).concerning("sketch")?;
167        let face = reader.retrieve_element(face_ref).concerning("face")?.id;
168        let runs = placed.runs().concerning("sketch")?;
169        if runs.is_empty() {
170            return Err(GeopError::new(format!(
171                "the sketch {:?} has no curve to divide the face {:?} along: construction curves do not divide",
172                placed.name,
173                face_ref.name()
174            ))
175            .concerning("sketch"));
176        }
177        let plane = &placed.plane;
178        let shells = match args.along {
179            Along::SketchNormal => {
180                let (from, to) = reach_across(model, face, plane)?;
181                let heights = [Scalar::from_f64(from), Scalar::from_f64(to)];
182                let mut shells = Vec::with_capacity(runs.len());
183                for run in runs {
184                    let lp = [SweepLoop::plain(run)];
185                    shells.extend(extrude(model, &namer, None, plane, &heights, &lp)?.shells);
186                }
187                shells
188            }
189            Along::FaceNormal => {
190                let mut chains = Vec::with_capacity(runs.len());
191                for run in runs {
192                    let curves = (run.curves.iter())
193                        .map(|c| c.embed(plane.origin(), plane.u(), plane.v()))
194                        .collect::<GeopResult<_>>()?;
195                    chains.push(Chain {
196                        name: run.name,
197                        curves,
198                        curve_names: run.curve_names,
199                        joint_names: run.joint_names,
200                    });
201                }
202                normal_sheets(model, &namer, face, &chains)?
203            }
204        };
205        let tools: Vec<Body> = shells.into_iter().map(Body::Sheet).collect();
206        imprint_on_face(model, &namer, face, &tools, RemeshParams::default()).with_context(
207            with_context!(
208                "dividing the face {:?} along the sketch {:?}",
209                face_ref.name(),
210                placed.name
211            ),
212        )?;
213        Ok(())
214    }
215}
216
217/// Points of a curve, per knot span of it, at which [`normal_sheets`] first
218/// finds where the curve is nearest the face.
219const FEET_PER_KNOT_SPAN: usize = 8;
220
221/// How far a rail of [`normal_sheets`] may be from the line along the
222/// normal it stands for, as a fraction of the face's extent, before the
223/// curve's feet are found twice as densely: how hard the fit tries, not
224/// what the sheet encloses — whatever the rail misses is padded on.
225const RAIL_DRIFT: f64 = 1e-7;
226
227/// How often [`normal_sheets`] doubles the feet it fits a rail through
228/// while the rail is seen to miss by more than [`RAIL_DRIFT`].
229const MOST_DOUBLINGS: usize = 5;
230
231/// How often [`rail_miss`] halves a stretch of the curve over which the
232/// bound on what the rails miss is wider than wanted: effort, not what the
233/// bound means.
234const MOST_HALVINGS: usize = 10;
235
236/// The sheets whose crossing with `face` is the projection of `chains`
237/// onto it, each point to the point of the face's surface nearest it: per
238/// chain a sheet of ruled faces, one per curve, through the line along the
239/// surface's normal at each foot point, reaching a sixteenth of the face's
240/// extent either side of it — a short reach, so that the sheet crosses
241/// the face there and nowhere else.
242///
243/// No NURBS is the projection of a curve onto a curved surface: the rails
244/// of each face are cubics through the ends of those lines, at the curve's
245/// own parameters of the points they were found for — `FEET_PER_KNOT_SPAN`
246/// per knot span at first, free choices. What a rail misses of the end of
247/// the line along the normal at every point of the curve is then bounded
248/// by [`rail_miss`], proven, and each rail padded by it, so that the sheet
249/// encloses every one of those lines and where it crosses the face
250/// encloses the projection. While a rail is seen to miss by more than
251/// [`RAIL_DRIFT`] of the face's extent halfway between the feet — a look
252/// that decides only how densely to find them — the feet are found twice
253/// as densely. The feet at
254/// a joint of a chain are found once, so the faces either side share the
255/// line there.
256///
257/// Each point goes to its nearest point at the curve's feet found first;
258/// between them, the foot followed is the one those continue into, proven
259/// to be the only one near.
260///
261/// Named after `namer`, each face after the curve `X` it is projected from,
262/// `N(X)`, as an extrude names its walls; its rails `N(X,near)` and
263/// `N(X,far)`, the line at joint `P` `N(P)` and its ends `N(P,near)` and
264/// `N(P,far)`.
265pub(crate) fn normal_sheets(
266    model: &mut NamedModel,
267    namer: &Namer,
268    face: FaceId,
269    chains: &[Chain<NurbCurve3D>],
270) -> GeopResult<Vec<ShellId>> {
271    let surface = model.topology().get_face(face)?.surface.clone();
272    let hull = face_hull(model.topology(), &[face])?;
273    let (lo, hi) = plain::bounds(hull.iter().map(|p| p.to_f64_array()))
274        .ok_or_else(|| GeopError::new(format!("face {face:?} has no control points")))?;
275    let extent = plain::diagonal(lo, hi);
276    let reach = Scalar::from_f64(extent / 16.0);
277    let drift = extent * RAIL_DRIFT;
278    // The point nearest `p`, where on the surface it is, and either end of
279    // the line along the normal there.
280    let ends = |p: &Vector3| -> GeopResult<((Scalar, Scalar), [Vector3; 2])> {
281        let (u, v, foot) = surface.closest_point(p)?;
282        let normal = surface.normal(u, v)?.prod_scalar(reach);
283        Ok(((u, v), [foot.add(&normal), foot.sub(&normal)]))
284    };
285    let mut shells = Vec::with_capacity(chains.len());
286    for chain in chains {
287        let n = chain.curves.len();
288        let closed = chain.is_closed();
289        let joints = chain.joints()?;
290        let mut spec = BodySpec::new(false);
291        let mut names = BodyNames::default();
292        let mut joint_feet = Vec::with_capacity(joints.len());
293        for (p, name) in joints.iter().zip(&chain.joint_names) {
294            let (at, [near, far]) = ends(p)?;
295            joint_feet.push(at);
296            spec.vertices.extend([near, far]);
297            names
298                .vertices
299                .extend(["near", "far"].map(|end| namer.name(&[name, end])));
300            spec.edges.push(EdgeSpec {
301                curve: NurbCurve3D::line(near, far)?,
302                start: spec.vertices.len() - 2,
303                end: spec.vertices.len() - 1,
304            });
305            names.edges.push(namer.name(&[name]));
306        }
307        let joint = |k: usize| if closed { k % n } else { k };
308        for (k, (curve, name)) in chain.curves.iter().zip(&chain.curve_names).enumerate() {
309            let (a, b) = (joint(k), joint(k + 1));
310            let (t0, t1) = curve.domain();
311            let mut count = FEET_PER_KNOT_SPAN * curve.breakpoints().len().saturating_sub(1).max(1);
312            let mut doublings = 0;
313            let (near, far) = loop {
314                let mut rails: [Vec<Vector4>; 2] = Default::default();
315                let mut params = Vec::with_capacity(count + 1);
316                let mut feet = Vec::with_capacity(count + 1);
317                for i in 0..=count {
318                    // Where along the curve is a free choice: sharp.
319                    let f = Scalar::from_f64(i as f64 / count as f64);
320                    let (foot, at) = if i == 0 {
321                        (
322                            joint_feet[a],
323                            [spec.vertices[2 * a], spec.vertices[2 * a + 1]],
324                        )
325                    } else if i == count {
326                        (
327                            joint_feet[b],
328                            [spec.vertices[2 * b], spec.vertices[2 * b + 1]],
329                        )
330                    } else {
331                        ends(&curve.evaluate(Scalar::interpolate(t0, t1, f).sharpen())?)?
332                    };
333                    for (rail, p) in rails.iter_mut().zip(at) {
334                        rail.push(p.homogeneous(Scalar::ONE));
335                    }
336                    params.push(f);
337                    feet.push(foot);
338                }
339                let [near, far] =
340                    rails.map(|rail| NurbCurve3D::interpolate_homogeneous(&rail, &params, 3));
341                let (mut near, mut far) = (near?, far?);
342                // A cheap look halfway between the feet says whether to
343                // find them more densely; the proof decides what to pad.
344                let mut seen = 0.0f64;
345                for (pair, &(u, v)) in params.windows(2).zip(&feet) {
346                    let f = pair[0].add(pair[1]).div(Scalar::TWO)?.sharpen();
347                    let p = curve.evaluate(Scalar::interpolate(t0, t1, f))?;
348                    let (u, v) = surface.project(p, u, v, PROJECT_ITERATIONS)?;
349                    let (foot, normal) = (surface.evaluate(u, v)?, surface.normal(u, v)?);
350                    let normal = normal.prod_scalar(reach);
351                    for (rail, end) in [(&near, foot.add(&normal)), (&far, foot.sub(&normal))] {
352                        seen = seen.max(rail.evaluate(f)?.sub(&end).norm().to_f64());
353                    }
354                }
355                if seen > drift / 4.0 && doublings < MOST_DOUBLINGS {
356                    count *= 2;
357                    doublings += 1;
358                    continue;
359                }
360                let miss = rail_miss(&surface, curve, [&near, &far], &params, &feet, reach, drift)
361                    .map_err(|e| {
362                        e.with_context(format!("projecting curve {name} along the face's normal"))
363                    })?;
364                let pad = [Scalar::from_f64(miss); 3];
365                near.widen(&pad);
366                far.widen(&pad);
367                break (near, far);
368            };
369            let surface = NurbSurface3D::ruled(&near, &far)?;
370            for (rail, (from, to), end) in [
371                (near, (2 * a, 2 * b), "near"),
372                (far, (2 * a + 1, 2 * b + 1), "far"),
373            ] {
374                spec.edges.push(EdgeSpec {
375                    curve: rail,
376                    start: from,
377                    end: to,
378                });
379                names.edges.push(namer.name(&[name, end]));
380            }
381            let rail = spec.edges.len() - 2;
382            spec.faces.push(FaceSpec::on_unit_square(
383                surface,
384                [
385                    CoedgeOn::Edge(rail, Sense::Forward),
386                    CoedgeOn::Edge(b, Sense::Forward),
387                    CoedgeOn::Edge(rail + 1, Sense::Reversed),
388                    CoedgeOn::Edge(a, Sense::Reversed),
389                ],
390            )?);
391            names.faces.push(namer.name(&[name]));
392        }
393        spec.shells.push((0..spec.faces.len()).collect());
394        shells.extend(model.build_body(spec, names)?.shells);
395    }
396    Ok(shells)
397}
398
399/// How far, at most, either rail of `rails` — fitted at the fractions
400/// `params` of `curve`'s domain, where the curve's feet on `surface` are
401/// at `feet` — misses the end of the line along the surface's normal,
402/// `reach` either side of the foot, at every point of the curve: proven,
403/// in every coordinate.
404///
405/// Over a stretch `I` of fractions, a rail `r` misses the line's end `R =
406/// f ± reach n` by `e = r − R`, and by the mean value theorem `e(I) ⊆
407/// e(c) + (r'(I) − R'(I)) (I − c)` about its middle `c`: `R' = f' ±
408/// reach n'`, how the foot and the normal there move as the curve's point
409/// does (see [`NurbSurface3D::foot_moving`], over the box its feet over
410/// `I` are proven to lie in). Between each two fractions fitted at, a
411/// stretch whose bound is wider than `drift` is halved, [`MOST_HALVINGS`]
412/// times at most; the bound is what is left, however wide.
413fn rail_miss(
414    surface: &NurbSurface3D,
415    curve: &NurbCurve3D,
416    rails: [&NurbCurve3D; 2],
417    params: &[Scalar],
418    feet: &[(Scalar, Scalar)],
419    reach: Scalar,
420    drift: f64,
421) -> GeopResult<f64> {
422    let (t0, t1) = curve.domain();
423    let span = t1.sub(t0);
424    let mut worst = 0.0f64;
425    for (pair, &seed) in params.windows(2).zip(feet) {
426        let mut seed = seed;
427        let mut stack = vec![(pair[0], pair[1], 0usize)];
428        while let Some((lo, hi, depth)) = stack.pop() {
429            let stretch = lo.union(hi);
430            let middle = lo.add(hi).div(Scalar::TWO)?.sharpen();
431            let at = |f: Scalar| curve.evaluate(Scalar::interpolate(t0, t1, f));
432            let moving = curve
433                .tangent(Scalar::interpolate(t0, t1, stretch))?
434                .prod_scalar(span);
435            let over = surface.foot_moving(&at(stretch)?, &moving, seed)?;
436            let here = surface.foot_moving(&at(middle)?, &Vector3::zero(), seed)?;
437            let (Some(over), Some(here)) = (&over, &here) else {
438                if depth < MOST_HALVINGS {
439                    stack.push((middle, hi, depth + 1));
440                    stack.push((lo, middle, depth + 1));
441                    continue;
442                }
443                return Err(GeopError::new(format!(
444                    "no foot point on the face proven for the curve's points at {stretch:?} of its domain: projected along the face's normal, a curve has to stay over the face's surface (the points {:?}, from a foot at {seed:?}, proven {} over them and {} at their middle)",
445                    at(stretch)?,
446                    if over.is_some() { "" } else { "none" },
447                    if here.is_some() { "" } else { "none" },
448                )));
449            };
450            seed = (here.at.0.midpoint(), here.at.1.midpoint());
451            let mut miss = 0.0f64;
452            for (rail, side) in rails.iter().zip([Scalar::ONE, Scalar::ONE.neg()]) {
453                let end = here.point.add(&here.normal.prod_scalar(reach.mul(side)));
454                let ends_move = over
455                    .velocity
456                    .add(&over.turning.prod_scalar(reach.mul(side)));
457                let off = rail.evaluate(middle)?.sub(&end);
458                let turn = rail.tangent(stretch)?.sub(&ends_move);
459                let e = off.add(&turn.prod_scalar(stretch.sub(middle)));
460                for k in 0..3 {
461                    miss = miss.max(e[k].abs().upper().to_f64());
462                }
463            }
464            if miss > drift / 2.0 && depth < MOST_HALVINGS {
465                stack.push((middle, hi, depth + 1));
466                stack.push((lo, middle, depth + 1));
467                continue;
468            }
469            worst = worst.max(miss);
470        }
471    }
472    Ok(worst)
473}
474
475/// How far along `plane`'s normal, back and forth from the plane, a sweep
476/// has to reach to cross all of `face`: past the convex hull of its
477/// surface's control points — a NURBS surface lies within it — by the
478/// hull's diagonal both ways, so the sweep's ends are clear of the face.
479fn reach_across(
480    model: &NamedModel,
481    face: FaceId,
482    plane: &CoordinateSystem,
483) -> GeopResult<(f64, f64)> {
484    let hull = face_hull(model.topology(), &[face])?;
485    let (near, far) = spread_along(&hull, plane);
486    let (lo, hi) = plain::bounds(hull.iter().map(|p| p.to_f64_array()))
487        .ok_or_else(|| GeopError::new(format!("face {face:?} has no control points")))?;
488    let diagonal = plain::diagonal(lo, hi);
489    Ok((near - diagonal, far + diagonal))
490}