Skip to main content

ogeom_offset/
draft.rs

1//! Draft: turning faces about a neutral plane so a part can leave its mould.
2//!
3//! A drafted face is the same face on a *tilted* support. It keeps the line
4//! where it crosses the neutral plane — that line does not move, which is
5//! what makes the draft measurable from a datum — and turns about it by the
6//! draft angle. Everything else follows: the neighbouring faces re-meet the
7//! tilted plane, the vertices re-solve, and the solid comes back with the
8//! same topology on new geometry.
9//!
10//! That last part is not this module's work. It is the offset's rebuild,
11//! which already puts a solid back together on moved supports; a draft
12//! hands it turned surfaces instead of translated ones.
13
14use ogeom_algo::Built;
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::{PlaneSurface, Surface as _, SurfaceGeometry};
17use ogeom_math::{Direction, Frame, Plane, Point, Transform, Vector};
18use ogeom_topo::{Model, NodeData, Shape, ShapeType, TShapeId};
19
20use crate::shape::rebuilt;
21
22/// Draft the named faces of a solid about a neutral plane.
23///
24/// Each face turns about its own intersection with `neutral` by `angle`,
25/// in the sense that leans the face inwards as it goes: a positive angle
26/// narrows the solid in the `pull` direction — the way the part leaves its
27/// mould — and a negative one widens it. Leaning inwards tilts the face's
28/// outward normal *towards* the pull, which is how the sense is picked,
29/// measured rather than assumed from a convention nobody can check. A face parallel
30/// to the neutral plane has no line to turn about and is refused by name,
31/// as is a face the rebuild cannot re-meet.
32///
33/// # Errors
34///
35/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
36/// named face is not a planar face of `solid`, is parallel to the neutral
37/// plane, or the angle is not a usable one; plus whatever the rebuild
38/// refuses.
39pub fn apply_draft(
40    model: &mut Model,
41    solid: &Shape,
42    faces: &[Shape],
43    neutral: Plane,
44    pull: Direction,
45    angle: f64,
46    tol: Tolerances,
47) -> OgeomResult<Built> {
48    if !angle.is_finite() || angle.abs() >= core::f64::consts::FRAC_PI_2 {
49        ogeom_bail!(
50            Construction,
51            "a draft of {angle} radians turns the face past its own plane"
52        );
53    }
54    let (canonical, mapped, prefix) = crate::shape::canonical_input(model, solid, faces, tol)?;
55    if let Some(prefix) = prefix {
56        let mut out = apply_draft(model, &canonical, &mapped, neutral, pull, angle, tol)?;
57        out.history = prefix.then(&out.history);
58        return Ok(out);
59    }
60    if faces.is_empty() {
61        ogeom_bail!(Construction, "a draft of no faces drafts nothing");
62    }
63    // The solid's own face occurrences, orientation and all: a handle a
64    // caller got from a canonical exploration carries no use-orientation,
65    // and the draft's sense probe needs the true outward.
66    let own: Vec<Shape> = {
67        let mut seen: Vec<Shape> = Vec::new();
68        for f in ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))? {
69            if !seen.iter().any(|s| s.node() == f.node()) {
70                seen.push(f);
71            }
72        }
73        seen
74    };
75
76    // The turned surface for each named face, worked out before the
77    // rebuild, so a face that cannot be drafted says so here rather than
78    // half-way through a solid.
79    let mut turned: Vec<(TShapeId, SurfaceGeometry)> = Vec::with_capacity(faces.len());
80    for face in faces {
81        let Some(used) = own.iter().find(|f| f.node() == face.node()).cloned() else {
82            ogeom_bail!(Construction, "a drafted face is not a face of the solid");
83        };
84        let face = &used;
85        let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
86            ogeom_bail!(Construction, "expected a face");
87        };
88        let Some(surface) = model.geometry().surface(data.surface) else {
89            ogeom_bail!(Dangling, "face refers to a surface not in this model");
90        };
91        // Which sign turns the raw surface normal outward, read from the
92        // material itself rather than from an orientation flag a handle may
93        // or may not carry: a step along the raw normal that lands inside
94        // the solid means the raw normal points inward.
95        let sign = outward_sign(model, solid, face, surface, tol)?;
96        let sign_of = |_: &Shape| sign;
97        // A wall of revolution drafts about its neutral *circle*: the same
98        // axis, the radius at the neutral plane held, the slant turned.
99        match surface {
100            SurfaceGeometry::Cylinder(c) => {
101                let cylinder = c.cylinder();
102                let (_, (v0, v1)) = surface.domain();
103                turned.push((
104                    face.node(),
105                    revolved_draft(
106                        cylinder.frame(),
107                        cylinder.radius(),
108                        0.0,
109                        (v0, v1),
110                        sign_of(face),
111                        neutral,
112                        pull,
113                        angle,
114                        tol,
115                    )?,
116                ));
117                continue;
118            }
119            SurfaceGeometry::Cone(co) => {
120                let cone = co.cone();
121                let (_, (v0, v1)) = surface.domain();
122                turned.push((
123                    face.node(),
124                    revolved_draft(
125                        cone.frame(),
126                        cone.reference_radius(),
127                        cone.half_angle(),
128                        (v0, v1),
129                        sign_of(face),
130                        neutral,
131                        pull,
132                        angle,
133                        tol,
134                    )?,
135                ));
136                continue;
137            }
138            SurfaceGeometry::Extrusion(e) => {
139                turned.push((
140                    face.node(),
141                    extruded_draft(
142                        e,
143                        surface.domain(),
144                        sign_of(face),
145                        neutral,
146                        pull,
147                        angle,
148                        tol,
149                    )?,
150                ));
151                continue;
152            }
153            SurfaceGeometry::Plane(_) => {}
154            _ => ogeom_bail!(
155                Construction,
156                "drafting a face that is neither planar, a wall of \
157                 revolution nor an extruded wall needs a support the \
158                 rebuild cannot turn — docs/PARITY.md, offset.draft"
159            ),
160        }
161        let SurfaceGeometry::Plane(p) = surface else {
162            unreachable!("the match above let only planes through");
163        };
164        let plane = p.plane();
165        let ((u0, u1), (v0, v1)) = surface.domain();
166        // The plane's raw normal is its du x dv; the measured sign turns it
167        // outward.
168        let outward = plane.normal().vector() * sign;
169
170        // The hinge: the line where this face crosses the neutral plane.
171        let along = plane.normal().vector().cross(neutral.normal().vector());
172        let magnitude = along.magnitude();
173        if magnitude <= tol.angular() {
174            ogeom_bail!(
175                Construction,
176                "a face parallel to the neutral plane has no line to turn \
177                 about"
178            );
179        }
180        let along = along / magnitude;
181        let hinge = meet(plane, neutral, along, tol)?;
182
183        // Which way to turn: probed at the angle's *magnitude*, so the
184        // sense names the inward lean — outward normal furthest towards the
185        // pull, the solid narrowing as it leaves — and the angle's sign
186        // stays the caller's: positive drafts inward, negative outward.
187        let axis = ogeom_math::Axis::new(hinge, Direction::new(along, tol)?);
188        let mut candidates = Vec::with_capacity(2);
189        for sense in [1.0, -1.0] {
190            let turn = Transform::rotation(axis, angle.abs() * sense);
191            candidates.push((sense, turn.apply_vector(outward).dot(pull.vector())));
192        }
193        let leaning = candidates
194            .iter()
195            .copied()
196            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
197            .map_or(1.0, |(sense, _)| sense);
198        let turn = Transform::rotation(axis, angle * leaning);
199        let moved_normal = Direction::new(turn.apply_vector(plane.normal().vector()), tol)?;
200        let tilted = Plane::new(Frame::new(
201            hinge,
202            moved_normal,
203            Direction::new(along, tol)?,
204            tol,
205        )?);
206        // The window grows with the turn: a tilted plane reaches further
207        // across the same solid than the one it replaces.
208        let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
209            + tol.confusion();
210        turned.push((
211            face.node(),
212            PlaneSurface::over(tilted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?.into(),
213        ));
214    }
215
216    rebuilt(
217        model,
218        solid,
219        &|_| 0.0,
220        &|face| {
221            turned
222                .iter()
223                .find(|(node, _)| *node == face.node())
224                .map(|(_, surface)| surface.clone())
225        },
226        tol,
227    )
228}
229
230/// The turned support for a drafted wall of revolution: a cone about the
231/// same axis, holding the radius at the neutral plane and leaning the slant
232/// by the draft, in the sense that tips the outward normal towards the pull.
233#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
234fn revolved_draft(
235    frame: Frame,
236    reference_radius: f64,
237    half_angle: f64,
238    window: (f64, f64),
239    sign: f64,
240    neutral: Plane,
241    pull: Direction,
242    angle: f64,
243    tol: Tolerances,
244) -> OgeomResult<SurfaceGeometry> {
245    use ogeom_geom::ConeSurface;
246
247    let axis_dir = frame.z().vector();
248    let along = axis_dir.dot(neutral.normal().vector());
249    if (along.abs() - 1.0).abs() > tol.angular().max(1e-9) {
250        ogeom_bail!(
251            Construction,
252            "a wall of revolution drafts about a neutral plane square to \
253             its axis; the oblique neutral needs the general machinery — \
254             docs/PARITY.md, offset.draft"
255        );
256    }
257    // The neutral circle: where the axis meets the plane, and the radius
258    // the wall holds there.
259    let height = -neutral.signed_distance_to(frame.origin()) * along.signum();
260    let neutral_point = frame.origin() + axis_dir * height;
261    let neutral_radius = half_angle.tan().mul_add(height, reference_radius);
262    if neutral_radius <= tol.confusion() {
263        ogeom_bail!(
264            Construction,
265            "the wall has no radius left at the neutral plane to hold"
266        );
267    }
268    let hinge_frame = Frame::new(neutral_point, frame.z(), frame.x(), tol)?;
269
270    // Which way to lean, by measurement: of the two candidate slants, keep
271    // the one whose outward normal — probed a little above the neutral
272    // circle — ends up leaning furthest towards the pull.
273    let mut best: Option<(f64, f64)> = None;
274    for sense in [1.0_f64, -1.0] {
275        // Probed at the magnitude: the sense names the inward lean, and the
276        // caller's sign then picks inward or outward through it.
277        let probe = half_angle + angle.abs() * sense;
278        let candidate = half_angle + angle * sense;
279        if probe.abs() <= tol.angular()
280            || probe.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
281            || candidate.abs() <= tol.angular()
282            || candidate.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
283        {
284            continue;
285        }
286        let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, probe, tol)?;
287        let surface: SurfaceGeometry = ConeSurface::new(cone, (-1.0, 1.0))?.into();
288        let (du, dv) = surface.d1_at(0.0, 1.0, tol)?;
289        let n = du.cross(dv);
290        let outward = n / n.magnitude() * sign;
291        let lean = outward.dot(pull.vector());
292        if best.as_ref().is_none_or(|(_, held)| lean > *held) {
293            best = Some((candidate, lean));
294        }
295    }
296    let Some((leaned, _)) = best else {
297        ogeom_bail!(
298            Construction,
299            "a draft of {angle} radians flattens the wall or swallows it"
300        );
301    };
302    let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, leaned, tol)?;
303
304    // The old window, re-expressed against the neutral origin and grown a
305    // little; refused when the slant runs out of radius inside it.
306    let shift = height;
307    let grow = (window.1 - window.0).abs().mul_add(0.1, 1.0);
308    let (w0, w1) = (window.0 - shift - grow, window.1 - shift + grow);
309    let apex_height = -neutral_radius / leaned.tan();
310    if apex_height > w0 && apex_height < w1 {
311        ogeom_bail!(
312            Construction,
313            "the draft swallows the drafted face's own apex"
314        );
315    }
316    Ok(ConeSurface::new(cone, (w0, w1))?.into())
317}
318
319/// The turned support for a drafted extruded wall: every ruling rotated
320/// about the hinge curve's own tangent by the draft, the result re-fitted.
321///
322/// The hinge is where the wall crosses the neutral plane — one closed-form
323/// height per profile parameter — and it does not move, exactly as a planar
324/// draft's hinge line does not. Each ruling turns about the hinge's local
325/// tangent in the sense that leans the outward normal towards the pull,
326/// probed at the profile's midpoint the way the planar draft probes its
327/// candidates. The turned rulings are sampled on a grid and fitted; a draft
328/// whose rulings cross inside the drafted window — a concave profile turned
329/// far enough to fold — is refused by name before anything is fitted.
330#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
331fn extruded_draft(
332    extrusion: &ogeom_geom::ExtrusionSurface,
333    window: ((f64, f64), (f64, f64)),
334    sign: f64,
335    neutral: Plane,
336    pull: Direction,
337    angle: f64,
338    tol: Tolerances,
339) -> OgeomResult<SurfaceGeometry> {
340    use ogeom_geom::Curve3d as _;
341    let ((u0, u1), (v0, v1)) = window;
342    let d = extrusion.direction().vector();
343    let n = neutral.normal().vector();
344    let den = n.dot(d);
345    if den.abs() <= tol.angular() {
346        ogeom_bail!(
347            Construction,
348            "the neutral plane runs along the wall's rulings; there is no \
349             hinge to turn about"
350        );
351    }
352    let curve = extrusion.curve();
353    let o = neutral.origin().to_vector();
354    // Where the ruling through C(u) crosses the neutral plane, and which way
355    // the hinge runs there.
356    let height_at = |c: Point| n.dot(o - c.to_vector()) / den;
357    let hinge_tangent = |cd: Vector| cd - d * (n.dot(cd) / den);
358
359    // The sense, probed at the profile's midpoint exactly as the planar
360    // draft probes its two candidates: the turn whose outward normal leans
361    // furthest towards the pull is the inward one, and the caller's sign
362    // picks inward or outward through it.
363    let um = f64::midpoint(u0, u1);
364    let cm = curve.point_at(um, tol)?;
365    let cdm = curve.d1_at(um, tol)?;
366    let hinge_m = cm + d * height_at(cm);
367    let tangent_m = Direction::new(hinge_tangent(cdm), tol)?;
368    let outward = {
369        let nw = cdm.cross(d);
370        nw / nw.magnitude() * sign
371    };
372    let axis_m = ogeom_math::Axis::new(hinge_m, tangent_m);
373    let mut leaning = 1.0;
374    let mut best = f64::NEG_INFINITY;
375    for sense in [1.0_f64, -1.0] {
376        let turn = Transform::rotation(axis_m, angle.abs() * sense);
377        let lean = turn.apply_vector(outward).dot(pull.vector());
378        if lean > best {
379            best = lean;
380            leaning = sense;
381        }
382    }
383    let theta = angle * leaning;
384
385    // One ruling per sample: the hinge point, the hinge tangent, and the
386    // extrusion direction turned about it.
387    const ALONG: usize = 65;
388    let mut hinges: Vec<Point> = Vec::with_capacity(ALONG);
389    let mut rulings: Vec<Vector> = Vec::with_capacity(ALONG);
390    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
391    for i in 0..ALONG {
392        #[allow(clippy::cast_precision_loss)]
393        let u = u0 + (u1 - u0) * (i as f64) / ((ALONG - 1) as f64);
394        let c = curve.point_at(u, tol)?;
395        let cd = curve.d1_at(u, tol)?;
396        let h = height_at(c);
397        let hinge = c + d * h;
398        let tangent = Direction::new(hinge_tangent(cd), tol)?;
399        let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
400        hinges.push(hinge);
401        rulings.push(turn.apply_vector(d));
402        s_lo = s_lo.min(v0 - h);
403        s_hi = s_hi.max(v1 - h);
404    }
405    // The window grows with the turn, as the planar draft's does. Along the
406    // profile the curve itself ends, so the growth is a tangent-line
407    // continuation at each end: the wall must still reach the neighbours it
408    // re-meets, and the continuation exists only to be trimmed away there.
409    let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
410        + tol.confusion();
411    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
412    {
413        // Quadratic continuation, so the fitted wall keeps its end
414        // curvature across the join instead of kinking straight.
415        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
416            let (i0, i1, i2) = if front {
417                (0, 1, 2)
418            } else {
419                let n = hinges.len();
420                (n - 1, n - 2, n - 3)
421            };
422            let d1 = hinges[i0] - hinges[i1];
423            let d2 = (hinges[i0] - hinges[i1]) - (hinges[i1] - hinges[i2]);
424            let r1 = rulings[i0] - rulings[i1];
425            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
426            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
427            let steps = (steps as usize).min(16);
428            for k in 1..=steps {
429                #[allow(clippy::cast_precision_loss)]
430                let k = k as f64;
431                let station = (
432                    hinges[i0] + d1 * k + d2 * (k * (k + 1.0) / 2.0),
433                    rulings[i0] + r1 * k,
434                );
435                if front {
436                    hinges.insert(0, station.0);
437                    rulings.insert(0, station.1);
438                } else {
439                    hinges.push(station.0);
440                    rulings.push(station.1);
441                }
442            }
443        };
444        extend(&mut hinges, &mut rulings, true);
445        extend(&mut hinges, &mut rulings, false);
446    }
447    let along_total = hinges.len();
448
449    // A fold is two rulings crossing inside the window: walking the wall at
450    // either extreme height must still advance the way the hinge advances.
451    for edge in [s_lo, s_hi] {
452        for i in 0..along_total - 1 {
453            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
454            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
455                ogeom_bail!(
456                    Construction,
457                    "the draft folds the wall onto itself inside the drafted \
458                     window; refused — docs/PARITY.md, offset.draft"
459                );
460            }
461        }
462    }
463
464    // Rulings are straight, so a handful of rows fits them exactly; the
465    // profile direction carries the shape.
466    const ACROSS: usize = 9;
467    let rows: Vec<Vec<Point>> = (0..ACROSS)
468        .map(|j| {
469            #[allow(clippy::cast_precision_loss)]
470            let s = s_lo + (s_hi - s_lo) * (j as f64) / ((ACROSS - 1) as f64);
471            (0..along_total)
472                .map(|i| hinges[i] + rulings[i] * s)
473                .collect()
474        })
475        .collect();
476    let fit_target = (tol.confusion() * 1e3).max(1e-4);
477    let fitted = ogeom_geom::fit::fit_surface_grid(&rows, 3, fit_target, tol)?;
478    if !fitted.met {
479        ogeom_bail!(
480            NotDone,
481            "the drafted wall's fit reached {} against a target of {fit_target}",
482            fitted.error
483        );
484    }
485    Ok(fitted.curve.into())
486}
487
488/// Which sign turns a surface's raw normal (du x dv) outward, read from
489/// the solid itself: probed a step off the face midpoint on both sides, at
490/// growing steps until one side is material and the other is not.
491///
492/// # Errors
493///
494/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if no
495/// probe separates the sides — a wall thinner than the probe can resolve.
496fn outward_sign(
497    model: &Model,
498    solid: &Shape,
499    face: &Shape,
500    surface: &SurfaceGeometry,
501    tol: Tolerances,
502) -> OgeomResult<f64> {
503    use ogeom_algo::Containment;
504    // A point genuinely on the face — the surface's domain midpoint may lie
505    // outside the trim — from the face's own triangulation, at its largest
506    // triangle's centre.
507    let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
508    let mut at = None;
509    let mut largest = 0.0_f64;
510    for t in &mesh.triangles {
511        let [a, b, c] = [
512            mesh.positions[t[0] as usize],
513            mesh.positions[t[1] as usize],
514            mesh.positions[t[2] as usize],
515        ];
516        let area = (b - a).cross(c - a).magnitude();
517        if area > largest {
518            largest = area;
519            let params = [
520                mesh.parameters[t[0] as usize],
521                mesh.parameters[t[1] as usize],
522                mesh.parameters[t[2] as usize],
523            ];
524            at = Some((
525                (params[0].0 + params[1].0 + params[2].0) / 3.0,
526                (params[0].1 + params[1].1 + params[2].1) / 3.0,
527            ));
528        }
529    }
530    let Some((um, vm)) = at else {
531        ogeom_bail!(Construction, "the drafted face has no interior to probe");
532    };
533    let p = surface.point_at(um, vm, tol)?;
534    let (du, dv) = surface.d1_at(um, vm, tol)?;
535    let n = du.cross(dv);
536    let m = n.magnitude();
537    if m <= tol.confusion() {
538        ogeom_bail!(Construction, "the face has no normal at its midpoint");
539    }
540    let n = n / m;
541    let scale = largest.sqrt().max(tol.confusion() * 1e3);
542    for eps_scale in [1e-3, 1e-2, 5e-2] {
543        let eps = scale * eps_scale;
544        let deflection = ogeom_mesh::Deflection {
545            chord: (eps * 0.1).max(1e-4),
546            ..ogeom_mesh::Deflection::default()
547        };
548        let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
549        let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
550        match (ahead, behind) {
551            (Containment::Out, Containment::In) => return Ok(1.0),
552            (Containment::In, Containment::Out) => return Ok(-1.0),
553            _ => {}
554        }
555    }
556    ogeom_bail!(
557        Construction,
558        "cannot read which side of the drafted face holds material; the          wall is thinner than the probe can resolve"
559    )
560}
561
562/// A point on the line where two planes meet, nearest their origins.
563fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
564    let rows = [a.normal().vector(), b.normal().vector(), along];
565    let rhs = [
566        rows[0].dot(a.origin().to_vector()),
567        rows[1].dot(b.origin().to_vector()),
568        along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
569    ];
570    let det = rows[0].dot(rows[1].cross(rows[2]));
571    if det.abs() <= tol.confusion() {
572        ogeom_bail!(Construction, "the two planes do not meet in a line");
573    }
574    Ok(Point::ORIGIN
575        + (rows[1].cross(rows[2]) * rhs[0]
576            + rows[2].cross(rows[0]) * rhs[1]
577            + rows[0].cross(rows[1]) * rhs[2])
578            / det)
579}