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
30/// parallel to the neutral plane has no line to turn about and is refused
31/// by name, 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        let axial = |frame: Frame| -> bool {
100            (frame.z().vector().dot(neutral.normal().vector()).abs() - 1.0).abs()
101                <= tol.angular().max(1e-9)
102        };
103        match surface {
104            SurfaceGeometry::Cylinder(c) if axial(c.cylinder().frame()) => {
105                let cylinder = c.cylinder();
106                let (_, (v0, v1)) = surface.domain();
107                turned.push((
108                    face.node(),
109                    revolved_draft(
110                        cylinder.frame(),
111                        cylinder.radius(),
112                        0.0,
113                        (v0, v1),
114                        sign_of(face),
115                        neutral,
116                        pull,
117                        angle,
118                        tol,
119                    )?,
120                ));
121                continue;
122            }
123            SurfaceGeometry::Cone(co) if axial(co.cone().frame()) => {
124                let cone = co.cone();
125                let (_, (v0, v1)) = surface.domain();
126                turned.push((
127                    face.node(),
128                    revolved_draft(
129                        cone.frame(),
130                        cone.reference_radius(),
131                        cone.half_angle(),
132                        (v0, v1),
133                        sign_of(face),
134                        neutral,
135                        pull,
136                        angle,
137                        tol,
138                    )?,
139                ));
140                continue;
141            }
142            SurfaceGeometry::Extrusion(e) => {
143                turned.push((
144                    face.node(),
145                    extruded_draft(
146                        e,
147                        surface.domain(),
148                        sign_of(face),
149                        neutral,
150                        pull,
151                        angle,
152                        tol,
153                    )?,
154                ));
155                continue;
156            }
157            SurfaceGeometry::Plane(_) => {}
158            // Everything else (a raw fitted patch, a wall of revolution
159            // about an oblique neutral) is drafted the way a mould-maker
160            // drafts: along the pull, tilted by the angle, through the
161            // line where the face crosses the neutral plane.
162            _ => {
163                turned.push((
164                    face.node(),
165                    general_draft(model, face, surface, sign, neutral, pull, angle, tol)?,
166                ));
167                continue;
168            }
169        }
170        let SurfaceGeometry::Plane(p) = surface else {
171            unreachable!("the match above let only planes through");
172        };
173        let plane = p.plane();
174        let ((u0, u1), (v0, v1)) = surface.domain();
175        // The plane's raw normal is its du x dv; the measured sign turns it
176        // outward.
177        let outward = plane.normal().vector() * sign;
178
179        // The hinge: the line where this face crosses the neutral plane.
180        let along = plane.normal().vector().cross(neutral.normal().vector());
181        let magnitude = along.magnitude();
182        if magnitude <= tol.angular() {
183            ogeom_bail!(
184                Construction,
185                "a face parallel to the neutral plane has no line to turn \
186                 about"
187            );
188        }
189        let along = along / magnitude;
190        let hinge = meet(plane, neutral, along, tol)?;
191
192        // Which way to turn: probed at the angle's *magnitude*, so the
193        // sense names the inward lean (outward normal furthest towards the
194        // pull, the solid narrowing as it leaves), and the angle's sign
195        // stays the caller's: positive drafts inward, negative outward.
196        let axis = ogeom_math::Axis::new(hinge, Direction::new(along, tol)?);
197        let mut candidates = Vec::with_capacity(2);
198        for sense in [1.0, -1.0] {
199            let turn = Transform::rotation(axis, angle.abs() * sense);
200            candidates.push((sense, turn.apply_vector(outward).dot(pull.vector())));
201        }
202        let leaning = candidates
203            .iter()
204            .copied()
205            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
206            .map_or(1.0, |(sense, _)| sense);
207        let turn = Transform::rotation(axis, angle * leaning);
208        let moved_normal = Direction::new(turn.apply_vector(plane.normal().vector()), tol)?;
209        let tilted = Plane::new(Frame::new(
210            hinge,
211            moved_normal,
212            Direction::new(along, tol)?,
213            tol,
214        )?);
215        // The window grows with the turn: a tilted plane reaches further
216        // across the same solid than the one it replaces.
217        let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
218            + tol.confusion();
219        turned.push((
220            face.node(),
221            PlaneSurface::over(tilted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?.into(),
222        ));
223    }
224
225    rebuilt(
226        model,
227        solid,
228        &|_| 0.0,
229        &|face| {
230            turned
231                .iter()
232                .find(|(node, _)| *node == face.node())
233                .map(|(_, surface)| surface.clone())
234        },
235        tol,
236    )
237}
238
239/// The turned support for a drafted wall of revolution: a cone about the
240/// same axis, holding the radius at the neutral plane and leaning the slant
241/// by the draft, in the sense that tips the outward normal towards the pull.
242#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
243fn revolved_draft(
244    frame: Frame,
245    reference_radius: f64,
246    half_angle: f64,
247    window: (f64, f64),
248    sign: f64,
249    neutral: Plane,
250    pull: Direction,
251    angle: f64,
252    tol: Tolerances,
253) -> OgeomResult<SurfaceGeometry> {
254    use ogeom_geom::ConeSurface;
255
256    let axis_dir = frame.z().vector();
257    let along = axis_dir.dot(neutral.normal().vector());
258    if (along.abs() - 1.0).abs() > tol.angular().max(1e-9) {
259        ogeom_bail!(
260            Construction,
261            "a wall of revolution drafts about a neutral plane square to \
262             its axis; the oblique neutral needs the general machinery (see \
263             docs/PARITY.md, offset.draft)"
264        );
265    }
266    // The neutral circle: where the axis meets the plane, and the radius
267    // the wall holds there.
268    let height = -neutral.signed_distance_to(frame.origin()) * along.signum();
269    let neutral_point = frame.origin() + axis_dir * height;
270    let neutral_radius = half_angle.tan().mul_add(height, reference_radius);
271    if neutral_radius <= tol.confusion() {
272        ogeom_bail!(
273            Construction,
274            "the wall has no radius left at the neutral plane to hold"
275        );
276    }
277    let hinge_frame = Frame::new(neutral_point, frame.z(), frame.x(), tol)?;
278
279    // Which way to lean, by measurement: of the two candidate slants, keep
280    // the one whose outward normal (probed a little above the neutral
281    // circle) ends up leaning furthest towards the pull.
282    let mut best: Option<(f64, f64)> = None;
283    for sense in [1.0_f64, -1.0] {
284        // Probed at the magnitude: the sense names the inward lean, and the
285        // caller's sign then picks inward or outward through it.
286        let probe = half_angle + angle.abs() * sense;
287        let candidate = half_angle + angle * sense;
288        if probe.abs() <= tol.angular()
289            || probe.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
290            || candidate.abs() <= tol.angular()
291            || candidate.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
292        {
293            continue;
294        }
295        let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, probe, tol)?;
296        let surface: SurfaceGeometry = ConeSurface::new(cone, (-1.0, 1.0))?.into();
297        let (du, dv) = surface.d1_at(0.0, 1.0, tol)?;
298        let n = du.cross(dv);
299        let outward = n / n.magnitude() * sign;
300        let lean = outward.dot(pull.vector());
301        if best.as_ref().is_none_or(|(_, held)| lean > *held) {
302            best = Some((candidate, lean));
303        }
304    }
305    let Some((leaned, _)) = best else {
306        ogeom_bail!(
307            Construction,
308            "a draft of {angle} radians flattens the wall or swallows it"
309        );
310    };
311    let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, leaned, tol)?;
312
313    // The old window, re-expressed against the neutral origin and grown a
314    // little; refused when the slant runs out of radius inside it.
315    let shift = height;
316    let grow = (window.1 - window.0).abs().mul_add(0.1, 1.0);
317    let (w0, w1) = (window.0 - shift - grow, window.1 - shift + grow);
318    let apex_height = -neutral_radius / leaned.tan();
319    if apex_height > w0 && apex_height < w1 {
320        ogeom_bail!(
321            Construction,
322            "the draft swallows the drafted face's own apex"
323        );
324    }
325    Ok(ConeSurface::new(cone, (w0, w1))?.into())
326}
327
328/// The turned support for a drafted extruded wall: every ruling rotated
329/// about the hinge curve's own tangent by the draft, the result re-fitted.
330///
331/// The hinge is where the wall crosses the neutral plane (one closed-form
332/// height per profile parameter), and it does not move, exactly as a planar
333/// draft's hinge line does not. Each ruling turns about the hinge's local
334/// tangent in the sense that leans the outward normal towards the pull,
335/// probed at the profile's midpoint the way the planar draft probes its
336/// candidates. The turned rulings are sampled on a grid and fitted; a draft
337/// whose rulings cross inside the drafted window (a concave profile turned
338/// far enough to fold) is refused by name before anything is fitted.
339#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
340fn extruded_draft(
341    extrusion: &ogeom_geom::ExtrusionSurface,
342    window: ((f64, f64), (f64, f64)),
343    sign: f64,
344    neutral: Plane,
345    pull: Direction,
346    angle: f64,
347    tol: Tolerances,
348) -> OgeomResult<SurfaceGeometry> {
349    use ogeom_geom::Curve3d as _;
350    let ((u0, u1), (v0, v1)) = window;
351    let d = extrusion.direction().vector();
352    let n = neutral.normal().vector();
353    let den = n.dot(d);
354    if den.abs() <= tol.angular() {
355        ogeom_bail!(
356            Construction,
357            "the neutral plane runs along the wall's rulings; there is no \
358             hinge to turn about"
359        );
360    }
361    let curve = extrusion.curve();
362    let o = neutral.origin().to_vector();
363    // Where the ruling through C(u) crosses the neutral plane, and which way
364    // the hinge runs there.
365    let height_at = |c: Point| n.dot(o - c.to_vector()) / den;
366    let hinge_tangent = |cd: Vector| cd - d * (n.dot(cd) / den);
367
368    // The sense, probed at the profile's midpoint exactly as the planar
369    // draft probes its two candidates: the turn whose outward normal leans
370    // furthest towards the pull is the inward one, and the caller's sign
371    // picks inward or outward through it.
372    let um = f64::midpoint(u0, u1);
373    let cm = curve.point_at(um, tol)?;
374    let cdm = curve.d1_at(um, tol)?;
375    let hinge_m = cm + d * height_at(cm);
376    let tangent_m = Direction::new(hinge_tangent(cdm), tol)?;
377    let outward = {
378        let nw = cdm.cross(d);
379        nw / nw.magnitude() * sign
380    };
381    let axis_m = ogeom_math::Axis::new(hinge_m, tangent_m);
382    let mut leaning = 1.0;
383    let mut best = f64::NEG_INFINITY;
384    for sense in [1.0_f64, -1.0] {
385        let turn = Transform::rotation(axis_m, angle.abs() * sense);
386        let lean = turn.apply_vector(outward).dot(pull.vector());
387        if lean > best {
388            best = lean;
389            leaning = sense;
390        }
391    }
392    let theta = angle * leaning;
393
394    // One ruling per sample: the hinge point, the hinge tangent, and the
395    // extrusion direction turned about it.
396    const ALONG: usize = 65;
397    let mut hinges: Vec<Point> = Vec::with_capacity(ALONG);
398    let mut rulings: Vec<Vector> = Vec::with_capacity(ALONG);
399    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
400    for i in 0..ALONG {
401        #[allow(clippy::cast_precision_loss)]
402        let u = u0 + (u1 - u0) * (i as f64) / ((ALONG - 1) as f64);
403        let c = curve.point_at(u, tol)?;
404        let cd = curve.d1_at(u, tol)?;
405        let h = height_at(c);
406        let hinge = c + d * h;
407        let tangent = Direction::new(hinge_tangent(cd), tol)?;
408        let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
409        hinges.push(hinge);
410        rulings.push(turn.apply_vector(d));
411        s_lo = s_lo.min(v0 - h);
412        s_hi = s_hi.max(v1 - h);
413    }
414    // The window grows with the turn, as the planar draft's does. Along the
415    // profile the curve itself ends, so the growth is a tangent-line
416    // continuation at each end: the wall must still reach the neighbours it
417    // re-meets, and the continuation exists only to be trimmed away there.
418    let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
419        + tol.confusion();
420    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
421    {
422        // Quadratic continuation, so the fitted wall keeps its end
423        // curvature across the join instead of kinking straight.
424        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
425            let (i0, i1, i2) = if front {
426                (0, 1, 2)
427            } else {
428                let n = hinges.len();
429                (n - 1, n - 2, n - 3)
430            };
431            let d1 = hinges[i0] - hinges[i1];
432            let d2 = (hinges[i0] - hinges[i1]) - (hinges[i1] - hinges[i2]);
433            let r1 = rulings[i0] - rulings[i1];
434            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
435            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
436            let steps = (steps as usize).min(16);
437            for k in 1..=steps {
438                #[allow(clippy::cast_precision_loss)]
439                let k = k as f64;
440                let station = (
441                    hinges[i0] + d1 * k + d2 * (k * (k + 1.0) / 2.0),
442                    rulings[i0] + r1 * k,
443                );
444                if front {
445                    hinges.insert(0, station.0);
446                    rulings.insert(0, station.1);
447                } else {
448                    hinges.push(station.0);
449                    rulings.push(station.1);
450                }
451            }
452        };
453        extend(&mut hinges, &mut rulings, true);
454        extend(&mut hinges, &mut rulings, false);
455    }
456    let along_total = hinges.len();
457
458    // A fold is two rulings crossing inside the window: walking the wall at
459    // either extreme height must still advance the way the hinge advances.
460    for edge in [s_lo, s_hi] {
461        for i in 0..along_total - 1 {
462            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
463            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
464                ogeom_bail!(
465                    Construction,
466                    "the draft folds the wall onto itself inside the drafted \
467                     window; refused; see docs/PARITY.md, offset.draft"
468                );
469            }
470        }
471    }
472
473    // Rulings are straight, so a handful of rows fits them exactly; the
474    // profile direction carries the shape.
475    const ACROSS: usize = 9;
476    let rows: Vec<Vec<Point>> = (0..ACROSS)
477        .map(|j| {
478            #[allow(clippy::cast_precision_loss)]
479            let s = s_lo + (s_hi - s_lo) * (j as f64) / ((ACROSS - 1) as f64);
480            (0..along_total)
481                .map(|i| hinges[i] + rulings[i] * s)
482                .collect()
483        })
484        .collect();
485    let fit_target = (tol.confusion() * 1e3).max(1e-4);
486    let fitted = ogeom_geom::fit::fit_surface_grid(&rows, 3, fit_target, tol)?;
487    if !fitted.met {
488        ogeom_bail!(
489            NotDone,
490            "the drafted wall's fit reached {} against a target of {fit_target}",
491            fitted.error
492        );
493    }
494    Ok(fitted.curve.into())
495}
496
497/// How many stations a general draft's hinge is sampled at, all round.
498const HINGE_STATIONS: usize = 256;
499
500/// The turned support for any face: the ruled surface through the face's
501/// crossing with the neutral plane, its rulings the pull direction turned
502/// about the crossing's tangent by the draft: what a mould-maker means by
503/// a draft, and what the planar and revolved paths are the closed forms
504/// of. The crossing is read off the face's own mesh and corrected onto the
505/// surface, so it lies inside the face whatever the surface's chart does.
506#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
507fn general_draft(
508    model: &Model,
509    face: &Shape,
510    surface: &SurfaceGeometry,
511    sign: f64,
512    neutral: Plane,
513    pull: Direction,
514    angle: f64,
515    tol: Tolerances,
516) -> OgeomResult<SurfaceGeometry> {
517    let n = neutral.normal().vector();
518    let mesh = ogeom_mesh::triangulate_face(
519        model,
520        face,
521        ogeom_mesh::Deflection {
522            chord: 0.05,
523            angular: 0.2,
524            ..ogeom_mesh::Deflection::default()
525        },
526        tol,
527    )?;
528    let extent = {
529        let b = mesh
530            .positions
531            .iter()
532            .fold(ogeom_math::Aabb::EMPTY, |acc, p| acc.with_point(*p));
533        match (b.low(), b.high()) {
534            (Some(lo), Some(hi)) => (hi - lo).magnitude(),
535            _ => ogeom_bail!(Construction, "the drafted face has no extent"),
536        }
537    };
538    if extent <= tol.confusion() {
539        ogeom_bail!(Construction, "the drafted face has no extent");
540    }
541
542    // The crossing, one segment per triangle the plane cuts, in the chart
543    // with its ends in space. A vertex the plane passes through (the hinge
544    // running along a rim, as a draft about a base does) is a crossing in
545    // itself, not a sign to read; read as one, rounding gives every rim
546    // triangle a hair of a segment pointing anywhere.
547    let on = tol.confusion() * 10.0;
548    let side: Vec<f64> = mesh
549        .positions
550        .iter()
551        .map(|p| neutral.signed_distance_to(*p))
552        .collect();
553    let mut segments: Vec<[((f64, f64), Point); 2]> = Vec::new();
554    for t in &mesh.triangles {
555        let mut ends: Vec<((f64, f64), Point)> = Vec::with_capacity(2);
556        for &corner in t {
557            let i = corner as usize;
558            if side[i].abs() <= on {
559                ends.push((mesh.parameters[i], mesh.positions[i]));
560            }
561        }
562        for k in 0..3 {
563            let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize);
564            let (a, b) = (side[i], side[j]);
565            if a.abs() <= on || b.abs() <= on || (a < 0.0) == (b < 0.0) {
566                continue;
567            }
568            let f = a / (a - b);
569            let (pa, pb) = (mesh.parameters[i], mesh.parameters[j]);
570            let (qa, qb) = (mesh.positions[i], mesh.positions[j]);
571            ends.push((
572                (pa.0 + (pb.0 - pa.0) * f, pa.1 + (pb.1 - pa.1) * f),
573                qa + (qb - qa) * f,
574            ));
575        }
576        ends.dedup_by(|a, b| a.1.distance(b.1) <= on);
577        if ends.len() == 2 && ends[0].1.distance(ends[1].1) > on {
578            segments.push([ends[0], ends[1]]);
579        }
580    }
581    if segments.is_empty() {
582        ogeom_bail!(
583            Construction,
584            "the neutral plane does not cross the drafted face; there is no \
585             hinge to turn about"
586        );
587    }
588    // Chained end to end into one run, closed or open, by where the ends
589    // are in space; across a seam the chart says two things and space one.
590    // Two runs is a face the plane crosses twice, which has no one hinge.
591    // A rim's segments come once per triangle on either side of it, so a
592    // segment already covered by the chain is dropped rather than chained.
593    let same = |a: Point, b: Point| a.distance(b) <= on;
594    let mut chain: Vec<((f64, f64), Point)> = vec![segments[0][0], segments[0][1]];
595    let mut used = vec![false; segments.len()];
596    used[0] = true;
597    loop {
598        let tail = chain[chain.len() - 1].1;
599        let head = chain[0].1;
600        let mut grew = false;
601        for (k, seg) in segments.iter().enumerate() {
602            if used[k] {
603                continue;
604            }
605            // The segment that joins the tail back to the head closes the
606            // run; it is kept, and the run is closed by it.
607            if (same(seg[0].1, tail) && same(seg[1].1, head))
608                || (same(seg[1].1, tail) && same(seg[0].1, head))
609            {
610                chain.push(chain[0]);
611                used[k] = true;
612                grew = true;
613                break;
614            }
615            let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
616            if covered(seg[0].1) && covered(seg[1].1) {
617                used[k] = true;
618                continue;
619            }
620            if same(seg[0].1, tail) {
621                chain.push(seg[1]);
622            } else if same(seg[1].1, tail) {
623                chain.push(seg[0]);
624            } else if same(seg[0].1, head) {
625                chain.insert(0, seg[1]);
626            } else if same(seg[1].1, head) {
627                chain.insert(0, seg[0]);
628            } else {
629                continue;
630            }
631            used[k] = true;
632            grew = true;
633        }
634        if !grew {
635            break;
636        }
637    }
638    if used.iter().any(|u| !u) {
639        ogeom_bail!(
640            Construction,
641            "the neutral plane crosses the drafted face more than once; \
642             there is no one hinge to turn about"
643        );
644    }
645    let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
646    if closed {
647        chain.pop();
648    }
649    if chain.len() < 2 {
650        ogeom_bail!(
651            Construction,
652            "the neutral plane touches the drafted face at a point; there is \
653             no hinge to turn about"
654        );
655    }
656    let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
657
658    // The mesh's stations are a chord apart; a cubic fitted through them
659    // sits a fraction of that chord off the true hinge. Resampled between
660    // them in the chart (across a periodic seam by the short way), and
661    // corrected onto the surface below, the stations are as dense as the
662    // fit's target wants.
663    let chain: Vec<(f64, f64)> = {
664        let ((ua, ub), (va, vb)) = surface.domain();
665        // Closed counts as periodic here: a fitted tube closes on itself
666        // without repeating, and a chain crossing its join must still take
667        // the short way round.
668        let period = (
669            (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
670            (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
671        );
672        let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
673            let d = b - a;
674            match period {
675                Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
676                _ => d,
677            }
678        };
679        // A closed hinge starts where it crosses the chart's own seam
680        // column, so the drafted support's seam stands where the old one
681        // did and the rebuild finds it there.
682        let chain: Vec<(f64, f64)> = if closed {
683            // The station exactly on the column: where the chain's segments
684            // cross `u = ua`, interpolated there; the nearest chain point
685            // otherwise.
686            let n = chain.len();
687            let mut exact: Option<(usize, (f64, f64))> = None;
688            for i in 0..n {
689                let (a, b) = (chain[i], chain[(i + 1) % n]);
690                let (da, db) = (short(ua, a.0, period.0), short(ua, b.0, period.0));
691                if da == 0.0 {
692                    exact = Some((i, a));
693                    break;
694                }
695                if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
696                    let f = da / (da - db);
697                    let dv = short(a.1, b.1, period.1);
698                    exact = Some((i + 1, (ua, a.1 + dv * f)));
699                    break;
700                }
701            }
702            let (start, inserted) = exact.unwrap_or_else(|| {
703                let mut best = (0usize, f64::INFINITY);
704                for (i, c) in chain.iter().enumerate() {
705                    let d = short(ua, c.0, period.0).abs();
706                    if d < best.1 {
707                        best = (i, d);
708                    }
709                }
710                (best.0, chain[best.0])
711            });
712            let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
713            rotated.push(inserted);
714            for k in 0..n {
715                let c = chain[(start + k) % n];
716                if rotated.len() == 1 && c == inserted {
717                    continue;
718                }
719                rotated.push(c);
720            }
721            rotated
722        } else {
723            chain
724        };
725        // Evenly by arc length, so the fit's parameter is the hinge's own
726        // length: the mesh's segments run from a hair to a chord, and a
727        // cubic through stations that uneven wanders between them.
728        let pairs = if closed { chain.len() } else { chain.len() - 1 };
729        let mut lengths = Vec::with_capacity(pairs);
730        let mut total = 0.0;
731        for i in 0..pairs {
732            let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
733            let step = surface
734                .point_at(a.0, a.1, tol)?
735                .distance(surface.point_at(b.0, b.1, tol)?);
736            lengths.push(step);
737            total += step;
738        }
739        let count = if closed {
740            HINGE_STATIONS
741        } else {
742            HINGE_STATIONS + 1
743        };
744        let mut dense = Vec::with_capacity(count);
745        let (mut pair, mut walked) = (0usize, 0.0_f64);
746        for k in 0..count {
747            #[allow(clippy::cast_precision_loss)]
748            let target = total * k as f64 / HINGE_STATIONS as f64;
749            while pair + 1 < pairs && walked + lengths[pair] < target {
750                walked += lengths[pair];
751                pair += 1;
752            }
753            let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
754            let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
755            let f = if lengths[pair] > 0.0 {
756                ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
757            } else {
758                0.0
759            };
760            dense.push((a.0 + du * f, a.1 + dv * f));
761        }
762        dense
763    };
764
765    // Each station corrected onto the surface's crossing with the plane
766    // (the mesh's chord is not the surface), and read for its tangent and
767    // outward normal.
768    let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
769    let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
770    let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
771    for (k, &(mut u, mut v)) in chain.iter().enumerate() {
772        // The first station of a closed hinge is the seam column's, and
773        // stays on it: corrected along `v` alone.
774        let pinned = closed && k == 0;
775        for _ in 0..8 {
776            let p = surface.point_at(u, v, tol)?;
777            let f = neutral.signed_distance_to(p);
778            if f.abs() <= tol.confusion() * 1e-2 {
779                break;
780            }
781            let (du, dv) = surface.d1_at(u, v, tol)?;
782            let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
783            let g2 = g.0 * g.0 + g.1 * g.1;
784            if g2 <= 0.0 {
785                break;
786            }
787            u -= f * g.0 / g2;
788            v -= f * g.1 / g2;
789        }
790        let p = surface.point_at(u, v, tol)?;
791        let (du, dv) = surface.d1_at(u, v, tol)?;
792        let raw = du.cross(dv);
793        if raw.magnitude() <= tol.confusion() {
794            ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
795        }
796        let outward = raw / raw.magnitude() * sign;
797        // Along the crossing: square to both normals, run the way the chain
798        // runs.
799        let mut t = outward.cross(n);
800        let next = chain[(k + 1) % chain.len()];
801        let prev = chain[(k + chain.len() - 1) % chain.len()];
802        let ahead =
803            surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
804        if t.dot(ahead) < 0.0 {
805            t = -t;
806        }
807        if t.magnitude() <= tol.angular() {
808            ogeom_bail!(
809                Construction,
810                "the neutral plane is tangent to the drafted face; there is no \
811                 hinge to turn about"
812            );
813        }
814        hinges.push(p);
815        tangents.push(t / t.magnitude());
816        outwards.push(outward);
817    }
818
819    // The sense, probed at the middle station as the other paths probe:
820    // the turn whose outward normal leans furthest towards the pull is the
821    // inward one, and the caller's sign picks inward or outward through it.
822    let m = hinges.len() / 2;
823    let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
824    let mut leaning = 1.0;
825    let mut best = f64::NEG_INFINITY;
826    for sense in [1.0_f64, -1.0] {
827        let turn = Transform::rotation(axis_m, angle.abs() * sense);
828        let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
829        if lean > best {
830            best = lean;
831            leaning = sense;
832        }
833    }
834    let theta = angle * leaning;
835
836    // One ruling per station: the pull turned about the hinge's tangent.
837    let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
838    for (hinge, tangent) in hinges.iter().zip(&tangents) {
839        let turn = Transform::rotation(
840            ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
841            theta,
842        );
843        rulings.push(turn.apply_vector(pull.vector()));
844    }
845    // How far the face reaches along the pull either side of its hinge,
846    // grown with the turn so the wall still reaches the neighbours it
847    // re-meets.
848    // Measured from every hinge station, not the middle one: an oblique
849    // hinge rises and falls along the pull, and a ruling from its low end
850    // must still reach the face's top.
851    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
852    let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
853    for h in &hinges {
854        let s = h.to_vector().dot(pull.vector());
855        h_lo = h_lo.min(s);
856        h_hi = h_hi.max(s);
857    }
858    for p in &mesh.positions {
859        let s = p.to_vector().dot(pull.vector());
860        s_lo = s_lo.min(s - h_hi);
861        s_hi = s_hi.max(s - h_lo);
862    }
863    let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
864    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
865    if !closed {
866        // An open hinge is continued straight past both ends, for the
867        // same reason.
868        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
869            let (i0, i1) = if front {
870                (0, 1)
871            } else {
872                (hinges.len() - 1, hinges.len() - 2)
873            };
874            let d1 = hinges[i0] - hinges[i1];
875            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
876            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
877            let steps = (steps as usize).min(16);
878            for k in 1..=steps {
879                #[allow(clippy::cast_precision_loss)]
880                let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
881                if front {
882                    hinges.insert(0, station.0);
883                    rulings.insert(0, station.1);
884                } else {
885                    hinges.push(station.0);
886                    rulings.push(station.1);
887                }
888            }
889        };
890        extend(&mut hinges, &mut rulings, true);
891        extend(&mut hinges, &mut rulings, false);
892    } else {
893        hinges.push(hinges[0]);
894        rulings.push(rulings[0]);
895    }
896    for edge in [s_lo, s_hi] {
897        for i in 0..hinges.len() - 1 {
898            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
899            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
900                ogeom_bail!(
901                    Construction,
902                    "the draft folds the wall onto itself inside the drafted \
903                     window; refused; see docs/PARITY.md, offset.draft"
904                );
905            }
906        }
907    }
908    // The ruled surface itself, exactly: the hinge fitted as a cubic, the
909    // rulings' tips fitted as another at the *same* parameters, the two
910    // brought onto one knot vector, and the surface linear between them:
911    // degree one along the ruling, so a straight line is a straight line
912    // and the fit's only error is the two curves' own. A grid fit through
913    // rows at several heights parameterizes each row by its own chord and
914    // averages, and rows that converge along their rulings disagree by
915    // enough for a cubic across them to wander.
916    // Chord-length parameters, not centripetal: the stations are as evenly
917    // spaced as the mesh's segments let them be, and centripetal
918    // parameters kink wherever the spacing changes, which a cubic then
919    // cannot follow. By chord the parameter is the arc length whatever
920    // the spacing.
921    let params: Vec<f64> = {
922        let mut out = Vec::with_capacity(hinges.len());
923        let mut total = 0.0;
924        out.push(0.0);
925        for pair in hinges.windows(2) {
926            total += pair[0].distance(pair[1]);
927            out.push(total);
928        }
929        if total > 0.0 {
930            for t in &mut out {
931                *t /= total;
932            }
933        }
934        if let Some(last) = out.last_mut() {
935            *last = 1.0;
936        }
937        out
938    };
939    let reach = s_lo.abs().max(s_hi.abs()).max(1.0);
940    let fit_target = (tol.confusion() * 1e3).max(1e-4);
941    let hinge_fit = ogeom_geom::fit::fit_points_at(&params, &hinges, 3, fit_target, tol)?;
942    let tips: Vec<Point> = hinges.iter().zip(&rulings).map(|(h, r)| *h + *r).collect();
943    let tip_fit = ogeom_geom::fit::fit_points_at(&params, &tips, 3, fit_target / reach, tol)?;
944    if !hinge_fit.met || !tip_fit.met {
945        ogeom_bail!(
946            NotDone,
947            "the drafted wall's hinge fit reached {} and its rulings' {} against \
948             a target of {fit_target}",
949            hinge_fit.error,
950            tip_fit.error * reach
951        );
952    }
953    let (mut hinge_curve, mut tip_curve) = (hinge_fit.curve, tip_fit.curve);
954    for (value, count) in tip_curve.knots().distinct() {
955        let have = hinge_curve.knots().multiplicity_of(value);
956        if count > have {
957            hinge_curve = hinge_curve.with_knot_inserted(value, count - have, tol)?;
958        }
959    }
960    for (value, count) in hinge_curve.knots().distinct() {
961        let have = tip_curve.knots().multiplicity_of(value);
962        if count > have {
963            tip_curve = tip_curve.with_knot_inserted(value, count - have, tol)?;
964        }
965    }
966    let (hc, tc) = (hinge_curve.control_points(), tip_curve.control_points());
967    if hc.len() != tc.len() {
968        ogeom_bail!(
969            Construction,
970            "the hinge and its rulings did not share a knot vector"
971        );
972    }
973    let mut net: Vec<Point> = Vec::with_capacity(hc.len() * 2);
974    for (h, t) in hc.iter().zip(tc) {
975        let (h, d) = (h.point(), t.point() - h.point());
976        net.push(h + d * s_lo);
977        net.push(h + d * s_hi);
978    }
979    let grid = ogeom_math::ControlGrid::new(net, hc.len(), 2)?;
980    // The chart: `u` over the old surface's own `u` domain for a closed
981    // hinge, so the seam column carries over; `v` the height along the
982    // ruling in the model's own units.
983    let u_knots = if closed {
984        let ((ua, ub), _) = surface.domain();
985        hinge_curve.knots().reparameterized(ua, ub)?
986    } else {
987        hinge_curve.knots().clone()
988    };
989    let v_knots = ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(s_lo, s_hi)?;
990    Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
991}
992
993/// Which sign turns a surface's raw normal (du x dv) outward, read from
994/// the solid itself: probed a step off the face midpoint on both sides, at
995/// growing steps until one side is material and the other is not.
996///
997/// # Errors
998///
999/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if no
1000/// probe separates the sides: a wall thinner than the probe can resolve.
1001fn outward_sign(
1002    model: &Model,
1003    solid: &Shape,
1004    face: &Shape,
1005    surface: &SurfaceGeometry,
1006    tol: Tolerances,
1007) -> OgeomResult<f64> {
1008    use ogeom_algo::Containment;
1009    // A point genuinely on the face (the surface's domain midpoint may lie
1010    // outside the trim), from the face's own triangulation, at its largest
1011    // triangle's centre.
1012    let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1013    let mut at = None;
1014    let mut largest = 0.0_f64;
1015    for t in &mesh.triangles {
1016        let [a, b, c] = [
1017            mesh.positions[t[0] as usize],
1018            mesh.positions[t[1] as usize],
1019            mesh.positions[t[2] as usize],
1020        ];
1021        let area = (b - a).cross(c - a).magnitude();
1022        if area > largest {
1023            largest = area;
1024            let params = [
1025                mesh.parameters[t[0] as usize],
1026                mesh.parameters[t[1] as usize],
1027                mesh.parameters[t[2] as usize],
1028            ];
1029            at = Some((
1030                (params[0].0 + params[1].0 + params[2].0) / 3.0,
1031                (params[0].1 + params[1].1 + params[2].1) / 3.0,
1032            ));
1033        }
1034    }
1035    let Some((um, vm)) = at else {
1036        ogeom_bail!(Construction, "the drafted face has no interior to probe");
1037    };
1038    let p = surface.point_at(um, vm, tol)?;
1039    let (du, dv) = surface.d1_at(um, vm, tol)?;
1040    let n = du.cross(dv);
1041    let m = n.magnitude();
1042    if m <= tol.confusion() {
1043        ogeom_bail!(Construction, "the face has no normal at its midpoint");
1044    }
1045    let n = n / m;
1046    let scale = largest.sqrt().max(tol.confusion() * 1e3);
1047    for eps_scale in [1e-3, 1e-2, 5e-2] {
1048        let eps = scale * eps_scale;
1049        let deflection = ogeom_mesh::Deflection {
1050            chord: (eps * 0.1).max(1e-4),
1051            ..ogeom_mesh::Deflection::default()
1052        };
1053        let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1054        let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1055        match (ahead, behind) {
1056            (Containment::Out, Containment::In) => return Ok(1.0),
1057            (Containment::In, Containment::Out) => return Ok(-1.0),
1058            _ => {}
1059        }
1060    }
1061    ogeom_bail!(
1062        Construction,
1063        "cannot read which side of the drafted face holds material; the          wall is thinner than the probe can resolve"
1064    )
1065}
1066
1067/// A point on the line where two planes meet, nearest their origins.
1068fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1069    let rows = [a.normal().vector(), b.normal().vector(), along];
1070    let rhs = [
1071        rows[0].dot(a.origin().to_vector()),
1072        rows[1].dot(b.origin().to_vector()),
1073        along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1074    ];
1075    let det = rows[0].dot(rows[1].cross(rows[2]));
1076    if det.abs() <= tol.confusion() {
1077        ogeom_bail!(Construction, "the two planes do not meet in a line");
1078    }
1079    Ok(Point::ORIGIN
1080        + (rows[1].cross(rows[2]) * rhs[0]
1081            + rows[2].cross(rows[0]) * rhs[1]
1082            + rows[0].cross(rows[1]) * rhs[2])
1083            / det)
1084}