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 puts a solid back together on moved supports. A draft hands it
12//! 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, by
29/// measurement. A face parallel to the neutral plane has no line to turn
30/// about and is refused by name, as is a face the rebuild cannot re-meet.
31///
32/// # Errors
33///
34/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
35/// named face is not a planar face of `solid`, is parallel to the neutral
36/// plane, or the angle is not a usable one; plus whatever the rebuild
37/// refuses.
38pub fn apply_draft(
39    model: &mut Model,
40    solid: &Shape,
41    faces: &[Shape],
42    neutral: Plane,
43    pull: Direction,
44    angle: f64,
45    tol: Tolerances,
46) -> OgeomResult<Built> {
47    if !angle.is_finite() || angle.abs() >= core::f64::consts::FRAC_PI_2 {
48        ogeom_bail!(
49            Construction,
50            "a draft of {angle} radians turns the face past its own plane"
51        );
52    }
53    let (canonical, mapped, prefix) = crate::shape::canonical_input(model, solid, faces, tol)?;
54    if let Some(prefix) = prefix {
55        let mut out = apply_draft(model, &canonical, &mapped, neutral, pull, angle, tol)?;
56        out.history = prefix.then(&out.history);
57        return Ok(out);
58    }
59    if faces.is_empty() {
60        ogeom_bail!(Construction, "a draft of no faces drafts nothing");
61    }
62    // The solid's own face occurrences, orientation and all: a handle a
63    // caller got from a canonical exploration carries no use-orientation,
64    // and the draft's sense probe needs the true outward.
65    let own: Vec<Shape> = {
66        let mut seen: Vec<Shape> = Vec::new();
67        for f in ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))? {
68            if !seen.iter().any(|s| s.node() == f.node()) {
69                seen.push(f);
70            }
71        }
72        seen
73    };
74
75    // The turned surface for each named face, worked out before the
76    // rebuild, so a face that cannot be drafted says so here rather than
77    // half-way through a solid.
78    let mut turned: Vec<(TShapeId, SurfaceGeometry)> = Vec::with_capacity(faces.len());
79    for face in faces {
80        let Some(used) = own.iter().find(|f| f.node() == face.node()).cloned() else {
81            ogeom_bail!(Construction, "a drafted face is not a face of the solid");
82        };
83        let face = &used;
84        let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
85            ogeom_bail!(Construction, "expected a face");
86        };
87        let Some(surface) = model.geometry().surface(data.surface) else {
88            ogeom_bail!(Dangling, "face refers to a surface not in this model");
89        };
90        // Which sign turns the raw surface normal outward, read from the
91        // material itself rather than from an orientation flag a handle may
92        // or may not carry: a step along the raw normal that lands inside
93        // the solid means the raw normal points inward.
94        let sign = outward_sign(model, solid, face, surface, tol)?;
95        let sign_of = |_: &Shape| sign;
96        // A wall of revolution drafts about its neutral *circle*: the same
97        // axis, the radius at the neutral plane held, the slant turned.
98        let axial = |frame: Frame| -> bool {
99            (frame.z().vector().dot(neutral.normal().vector()).abs() - 1.0).abs()
100                <= tol.angular().max(1e-9)
101        };
102        match surface {
103            SurfaceGeometry::Cylinder(c) if axial(c.cylinder().frame()) => {
104                let cylinder = c.cylinder();
105                let (_, (v0, v1)) = surface.domain();
106                turned.push((
107                    face.node(),
108                    revolved_draft(
109                        cylinder.frame(),
110                        cylinder.radius(),
111                        0.0,
112                        (v0, v1),
113                        sign_of(face),
114                        neutral,
115                        pull,
116                        angle,
117                        tol,
118                    )?,
119                ));
120                continue;
121            }
122            SurfaceGeometry::Cone(co) if axial(co.cone().frame()) => {
123                let cone = co.cone();
124                let (_, (v0, v1)) = surface.domain();
125                turned.push((
126                    face.node(),
127                    revolved_draft(
128                        cone.frame(),
129                        cone.reference_radius(),
130                        cone.half_angle(),
131                        (v0, v1),
132                        sign_of(face),
133                        neutral,
134                        pull,
135                        angle,
136                        tol,
137                    )?,
138                ));
139                continue;
140            }
141            SurfaceGeometry::Extrusion(e) => {
142                turned.push((
143                    face.node(),
144                    extruded_draft(
145                        e,
146                        surface.domain(),
147                        sign_of(face),
148                        neutral,
149                        pull,
150                        angle,
151                        tol,
152                    )?,
153                ));
154                continue;
155            }
156            SurfaceGeometry::Plane(_) => {}
157            // Everything else (a raw fitted patch, a wall of revolution
158            // about an oblique neutral) is drafted the way a mould-maker
159            // drafts: along the pull, tilted by the angle, through the
160            // line where the face crosses the neutral plane.
161            _ => {
162                turned.push((
163                    face.node(),
164                    general_draft(model, face, surface, sign, neutral, pull, angle, tol)?,
165                ));
166                continue;
167            }
168        }
169        let SurfaceGeometry::Plane(p) = surface else {
170            unreachable!("the match above let only planes through");
171        };
172        let plane = p.plane();
173        let ((u0, u1), (v0, v1)) = surface.domain();
174        // The plane's raw normal is its du x dv; the measured sign turns it
175        // outward.
176        let outward = plane.normal().vector() * sign;
177
178        // The hinge: the line where this face crosses the neutral plane.
179        let along = plane.normal().vector().cross(neutral.normal().vector());
180        let magnitude = along.magnitude();
181        if magnitude <= tol.angular() {
182            ogeom_bail!(
183                Construction,
184                "a face parallel to the neutral plane has no line to turn \
185                 about"
186            );
187        }
188        let along = along / magnitude;
189        let hinge = meet(plane, neutral, along, tol)?;
190
191        // Which way to turn: probed at the angle's *magnitude*, so the
192        // sense names the inward lean (outward normal furthest towards the
193        // pull, the solid narrowing as it leaves), and the angle's sign
194        // stays the caller's: positive drafts inward, negative outward.
195        let axis = ogeom_math::Axis::new(hinge, Direction::new(along, tol)?);
196        let mut candidates = Vec::with_capacity(2);
197        for sense in [1.0, -1.0] {
198            let turn = Transform::rotation(axis, angle.abs() * sense);
199            candidates.push((sense, turn.apply_vector(outward).dot(pull.vector())));
200        }
201        let leaning = candidates
202            .iter()
203            .copied()
204            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
205            .map_or(1.0, |(sense, _)| sense);
206        let turn = Transform::rotation(axis, angle * leaning);
207        let moved_normal = Direction::new(turn.apply_vector(plane.normal().vector()), tol)?;
208        let tilted = Plane::new(Frame::new(
209            hinge,
210            moved_normal,
211            Direction::new(along, tol)?,
212            tol,
213        )?);
214        // The window grows with the turn: a tilted plane reaches further
215        // across the same solid than the one it replaces.
216        let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
217            + tol.confusion();
218        turned.push((
219            face.node(),
220            PlaneSurface::over(tilted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?.into(),
221        ));
222    }
223
224    rebuilt(
225        model,
226        solid,
227        &|_| 0.0,
228        &|face| {
229            turned
230                .iter()
231                .find(|(node, _)| *node == face.node())
232                .map(|(_, surface)| surface.clone())
233        },
234        tol,
235    )
236}
237
238/// The turned support for a drafted wall of revolution: a cone about the
239/// same axis, holding the radius at the neutral plane and leaning the slant
240/// by the draft, in the sense that tips the outward normal towards the pull.
241#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
242fn revolved_draft(
243    frame: Frame,
244    reference_radius: f64,
245    half_angle: f64,
246    window: (f64, f64),
247    sign: f64,
248    neutral: Plane,
249    pull: Direction,
250    angle: f64,
251    tol: Tolerances,
252) -> OgeomResult<SurfaceGeometry> {
253    use ogeom_geom::ConeSurface;
254
255    let axis_dir = frame.z().vector();
256    let along = axis_dir.dot(neutral.normal().vector());
257    if (along.abs() - 1.0).abs() > tol.angular().max(1e-9) {
258        ogeom_bail!(
259            Construction,
260            "a wall of revolution drafts about a neutral plane square to \
261             its axis; the oblique neutral needs the general machinery (see \
262             docs/PARITY.md, offset.draft)"
263        );
264    }
265    // The neutral circle: where the axis meets the plane, and the radius
266    // the wall holds there.
267    let height = -neutral.signed_distance_to(frame.origin()) * along.signum();
268    let neutral_point = frame.origin() + axis_dir * height;
269    let neutral_radius = half_angle.tan().mul_add(height, reference_radius);
270    if neutral_radius <= tol.confusion() {
271        ogeom_bail!(
272            Construction,
273            "the wall has no radius left at the neutral plane to hold"
274        );
275    }
276    let hinge_frame = Frame::new(neutral_point, frame.z(), frame.x(), tol)?;
277
278    // Which way to lean, by measurement: of the two candidate slants, keep
279    // the one whose outward normal (probed a little above the neutral
280    // circle) ends up leaning furthest towards the pull.
281    let mut best: Option<(f64, f64)> = None;
282    for sense in [1.0_f64, -1.0] {
283        // Probed at the magnitude: the sense names the inward lean, and the
284        // caller's sign then picks inward or outward through it.
285        let probe = half_angle + angle.abs() * sense;
286        let candidate = half_angle + angle * sense;
287        if probe.abs() <= tol.angular()
288            || probe.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
289            || candidate.abs() <= tol.angular()
290            || candidate.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
291        {
292            continue;
293        }
294        let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, probe, tol)?;
295        let surface: SurfaceGeometry = ConeSurface::new(cone, (-1.0, 1.0))?.into();
296        let (du, dv) = surface.d1_at(0.0, 1.0, tol)?;
297        let n = du.cross(dv);
298        let outward = n / n.magnitude() * sign;
299        let lean = outward.dot(pull.vector());
300        if best.as_ref().is_none_or(|(_, held)| lean > *held) {
301            best = Some((candidate, lean));
302        }
303    }
304    let Some((leaned, _)) = best else {
305        ogeom_bail!(
306            Construction,
307            "a draft of {angle} radians flattens the wall or swallows it"
308        );
309    };
310    let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, leaned, tol)?;
311
312    // The old window, re-expressed against the neutral origin and grown a
313    // little; refused when the slant runs out of radius inside it.
314    let shift = height;
315    let grow = (window.1 - window.0).abs().mul_add(0.1, 1.0);
316    let (w0, w1) = (window.0 - shift - grow, window.1 - shift + grow);
317    let apex_height = -neutral_radius / leaned.tan();
318    if apex_height > w0 && apex_height < w1 {
319        ogeom_bail!(
320            Construction,
321            "the draft swallows the drafted face's own apex"
322        );
323    }
324    Ok(ConeSurface::new(cone, (w0, w1))?.into())
325}
326
327/// The turned support for a drafted extruded wall: every ruling rotated
328/// about the hinge curve's own tangent by the draft, the result re-fitted.
329///
330/// The hinge is where the wall crosses the neutral plane (one closed-form
331/// height per profile parameter), and it does not move, exactly as a planar
332/// draft's hinge line does not. Each ruling turns about the hinge's local
333/// tangent in the sense that leans the outward normal towards the pull,
334/// probed at the profile's midpoint the way the planar draft probes its
335/// candidates. The turned rulings are sampled on a grid and fitted; a draft
336/// whose rulings cross inside the drafted window (a concave profile turned
337/// far enough to fold) is refused by name before anything is fitted.
338#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
339fn extruded_draft(
340    extrusion: &ogeom_geom::ExtrusionSurface,
341    window: ((f64, f64), (f64, f64)),
342    sign: f64,
343    neutral: Plane,
344    pull: Direction,
345    angle: f64,
346    tol: Tolerances,
347) -> OgeomResult<SurfaceGeometry> {
348    use ogeom_geom::Curve3d as _;
349    let ((u0, u1), (v0, v1)) = window;
350    let d = extrusion.direction().vector();
351    let n = neutral.normal().vector();
352    let den = n.dot(d);
353    if den.abs() <= tol.angular() {
354        ogeom_bail!(
355            Construction,
356            "the neutral plane runs along the wall's rulings; there is no \
357             hinge to turn about"
358        );
359    }
360    let curve = extrusion.curve();
361    let o = neutral.origin().to_vector();
362    // Where the ruling through C(u) crosses the neutral plane, and which way
363    // the hinge runs there.
364    let height_at = |c: Point| n.dot(o - c.to_vector()) / den;
365    let hinge_tangent = |cd: Vector| cd - d * (n.dot(cd) / den);
366
367    // The sense, probed at the profile's midpoint exactly as the planar
368    // draft probes its two candidates: the turn whose outward normal leans
369    // furthest towards the pull is the inward one, and the caller's sign
370    // picks inward or outward through it.
371    let um = f64::midpoint(u0, u1);
372    let cm = curve.point_at(um, tol)?;
373    let cdm = curve.d1_at(um, tol)?;
374    let hinge_m = cm + d * height_at(cm);
375    let tangent_m = Direction::new(hinge_tangent(cdm), tol)?;
376    let outward = {
377        let nw = cdm.cross(d);
378        nw / nw.magnitude() * sign
379    };
380    let axis_m = ogeom_math::Axis::new(hinge_m, tangent_m);
381    let mut leaning = 1.0;
382    let mut best = f64::NEG_INFINITY;
383    for sense in [1.0_f64, -1.0] {
384        let turn = Transform::rotation(axis_m, angle.abs() * sense);
385        let lean = turn.apply_vector(outward).dot(pull.vector());
386        if lean > best {
387            best = lean;
388            leaning = sense;
389        }
390    }
391    let theta = angle * leaning;
392
393    // One ruling per profile parameter: the hinge point, the hinge
394    // tangent, and the extrusion direction turned about it.
395    let ruling_at = |u: f64| -> OgeomResult<(Point, Vector, f64)> {
396        let c = curve.point_at(u, tol)?;
397        let cd = curve.d1_at(u, tol)?;
398        let h = height_at(c);
399        let hinge = c + d * h;
400        let tangent = Direction::new(hinge_tangent(cd), tol)?;
401        let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
402        Ok((hinge, turn.apply_vector(d), h))
403    };
404    const ALONG: usize = 65;
405    #[allow(clippy::cast_precision_loss)]
406    let along_u = |x: f64| u0 + (u1 - u0) * x / ((ALONG - 1) as f64);
407    let mut base: Vec<(Point, Vector)> = Vec::with_capacity(ALONG);
408    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
409    for i in 0..ALONG {
410        #[allow(clippy::cast_precision_loss)]
411        let (hinge, ruling, h) = ruling_at(along_u(i as f64))?;
412        base.push((hinge, ruling));
413        s_lo = s_lo.min(v0 - h);
414        s_hi = s_hi.max(v1 - h);
415    }
416    // The window grows with the turn, as the planar draft's does. Along the
417    // profile the curve itself ends, so the growth is a tangent-line
418    // continuation at each end: the wall must still reach the neighbours it
419    // re-meets, and the continuation exists only to be trimmed away there.
420    let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
421        + tol.confusion();
422    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
423    // Quadratic continuation, so the fitted wall keeps its end curvature
424    // across the join instead of kinking straight: `k` station spacings
425    // past the end, from the end's last three stations.
426    struct Continuation {
427        hinge: Point,
428        d1: Vector,
429        d2: Vector,
430        ruling: Vector,
431        r1: Vector,
432        steps: usize,
433    }
434    let continuation = |front: bool| -> Continuation {
435        let (i0, i1, i2) = if front {
436            (0, 1, 2)
437        } else {
438            (ALONG - 1, ALONG - 2, ALONG - 3)
439        };
440        let d1 = base[i0].0 - base[i1].0;
441        let d2 = (base[i0].0 - base[i1].0) - (base[i1].0 - base[i2].0);
442        // Far enough past the end that a neighbour standing across the
443        // wall at a slant still finds wall where the turned rulings have
444        // carried it sideways, as far as they lean at the window's far
445        // heights.
446        let step = d1.magnitude().max(tol.confusion());
447        let ruling = base[i0].1;
448        let lean = (ruling - d * ruling.dot(d)).magnitude() * s_lo.abs().max(s_hi.abs());
449        let steps = ((grow + lean) / step).ceil().max(2.0);
450        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
451        let steps = (steps as usize).min(16);
452        Continuation {
453            hinge: base[i0].0,
454            d1,
455            d2,
456            ruling: base[i0].1,
457            r1: base[i0].1 - base[i1].1,
458            steps,
459        }
460    };
461    let (front, back) = (continuation(true), continuation(false));
462    let continued = |c: &Continuation, k: f64| -> (Point, Vector) {
463        (
464            c.hinge + c.d1 * k + c.d2 * (k * (k + 1.0) / 2.0),
465            c.ruling + c.r1 * k,
466        )
467    };
468    // The wall's stations by index `x` along it: the front continuation,
469    // the profile's stations, the back continuation; between the
470    // profile's stations the profile itself, and between the
471    // continuation's its formula.
472    #[allow(clippy::cast_precision_loss)]
473    let (first, last) = (front.steps as f64, (front.steps + ALONG - 1) as f64);
474    let station = |x: f64| -> OgeomResult<(Point, Vector)> {
475        if x < first {
476            return Ok(continued(&front, first - x));
477        }
478        if x > last {
479            return Ok(continued(&back, x - last));
480        }
481        let (hinge, ruling, _) = ruling_at(along_u(x - first))?;
482        Ok((hinge, ruling))
483    };
484    let along_total = front.steps + ALONG + back.steps;
485    let stations: Vec<(Point, Vector)> = (0..along_total)
486        .map(|i| {
487            #[allow(clippy::cast_precision_loss)]
488            let x = i as f64;
489            match i.checked_sub(front.steps) {
490                Some(j) if j < ALONG => Ok(base[j]),
491                _ => station(x),
492            }
493        })
494        .collect::<OgeomResult<_>>()?;
495
496    // A fold is two rulings crossing inside the window: walking the wall at
497    // either extreme height must still advance the way the hinge advances.
498    for edge in [s_lo, s_hi] {
499        for pair in stations.windows(2) {
500            let ((h0, r0), (h1, r1)) = (pair[0], pair[1]);
501            let step = (h1 + r1 * edge) - (h0 + r0 * edge);
502            if step.dot(h1 - h0) <= 0.0 {
503                ogeom_bail!(
504                    Construction,
505                    "the draft folds the wall onto itself inside the drafted \
506                     window; refused; see docs/PARITY.md, offset.draft"
507                );
508            }
509        }
510    }
511
512    // Rulings are straight, so a handful of rows fits them exactly; the
513    // profile direction carries the shape, and is fitted to the turned
514    // rulings at the stations and between them, the stations refined
515    // where the wall misses.
516    const ACROSS: usize = 9;
517    // The chart runs over [0, 1] both ways: `u` across the window from its
518    // top down, `v` along the wall by station index, which keeps the
519    // extrusion's own sense (`du x dv` the same way round). The straight
520    // rulings take `u`, where a measure of the face integrates along it.
521    #[allow(clippy::cast_precision_loss)]
522    let span = (along_total - 1) as f64;
523    let xs: Vec<f64> = (0..along_total)
524        .map(|i| {
525            #[allow(clippy::cast_precision_loss)]
526            let x = i as f64 / span;
527            x
528        })
529        .collect();
530    let ss: Vec<f64> = (0..ACROSS)
531        .map(|j| {
532            #[allow(clippy::cast_precision_loss)]
533            let s = j as f64 / ((ACROSS - 1) as f64);
534            s
535        })
536        .collect();
537    let fit_target = (tol.confusion() * 1e3).max(1e-4);
538    let fitted = ogeom_geom::fit::fit_surface_sampled(
539        |s, x| {
540            let x = x * span;
541            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
542            let i = x.round() as usize;
543            #[allow(clippy::cast_precision_loss)]
544            let (hinge, ruling) = if (i as f64 - x).abs() <= 1e-12 * span && i < along_total {
545                stations[i]
546            } else {
547                station(x)?
548            };
549            Ok(hinge + ruling * (s_hi - (s_hi - s_lo) * s))
550        },
551        &ogeom_geom::fit::Sampling {
552            us: ss,
553            vs: xs,
554            between: (false, true),
555            closed_v: false,
556            most: 4096,
557        },
558        3,
559        fit_target,
560        tol,
561    )?;
562    if !fitted.met {
563        ogeom_bail!(
564            NotDone,
565            "the drafted wall's fit reached {} against a target of {fit_target}",
566            fitted.error
567        );
568    }
569    Ok(fitted.curve.into())
570}
571
572/// How many stations a general draft's hinge is sampled at, all round.
573const HINGE_STATIONS: usize = 256;
574
575/// The turned support for any face: the ruled surface through the face's
576/// crossing with the neutral plane, its rulings the pull direction turned
577/// about the crossing's tangent by the draft: what a mould-maker means by
578/// a draft, and what the planar and revolved paths are the closed forms
579/// of. The crossing is read off the face's own mesh and corrected onto the
580/// surface, so it lies inside the face whatever the surface's chart does.
581#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
582fn general_draft(
583    model: &Model,
584    face: &Shape,
585    surface: &SurfaceGeometry,
586    sign: f64,
587    neutral: Plane,
588    pull: Direction,
589    angle: f64,
590    tol: Tolerances,
591) -> OgeomResult<SurfaceGeometry> {
592    let n = neutral.normal().vector();
593    let mesh = ogeom_mesh::triangulate_face(
594        model,
595        face,
596        ogeom_mesh::Deflection {
597            chord: 0.05,
598            angular: 0.2,
599            ..ogeom_mesh::Deflection::default()
600        },
601        tol,
602    )?;
603    let extent = {
604        let b = mesh
605            .positions
606            .iter()
607            .fold(ogeom_math::Aabb::EMPTY, |acc, p| acc.with_point(*p));
608        match (b.low(), b.high()) {
609            (Some(lo), Some(hi)) => (hi - lo).magnitude(),
610            _ => ogeom_bail!(Construction, "the drafted face has no extent"),
611        }
612    };
613    if extent <= tol.confusion() {
614        ogeom_bail!(Construction, "the drafted face has no extent");
615    }
616
617    // The crossing, one segment per triangle the plane cuts, in the chart
618    // with its ends in space. A vertex the plane passes through (the hinge
619    // running along a rim, as a draft about a base does) is a crossing in
620    // itself, not a sign to read; read as one, rounding gives every rim
621    // triangle a hair of a segment pointing anywhere.
622    let on = tol.confusion() * 10.0;
623    let side: Vec<f64> = mesh
624        .positions
625        .iter()
626        .map(|p| neutral.signed_distance_to(*p))
627        .collect();
628    let mut segments: Vec<[((f64, f64), Point); 2]> = Vec::new();
629    for t in &mesh.triangles {
630        let mut ends: Vec<((f64, f64), Point)> = Vec::with_capacity(2);
631        for &corner in t {
632            let i = corner as usize;
633            if side[i].abs() <= on {
634                ends.push((mesh.parameters[i], mesh.positions[i]));
635            }
636        }
637        for k in 0..3 {
638            let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize);
639            let (a, b) = (side[i], side[j]);
640            if a.abs() <= on || b.abs() <= on || (a < 0.0) == (b < 0.0) {
641                continue;
642            }
643            let f = a / (a - b);
644            let (pa, pb) = (mesh.parameters[i], mesh.parameters[j]);
645            let (qa, qb) = (mesh.positions[i], mesh.positions[j]);
646            ends.push((
647                (pa.0 + (pb.0 - pa.0) * f, pa.1 + (pb.1 - pa.1) * f),
648                qa + (qb - qa) * f,
649            ));
650        }
651        ends.dedup_by(|a, b| a.1.distance(b.1) <= on);
652        if ends.len() == 2 && ends[0].1.distance(ends[1].1) > on {
653            segments.push([ends[0], ends[1]]);
654        }
655    }
656    if segments.is_empty() {
657        ogeom_bail!(
658            Construction,
659            "the neutral plane does not cross the drafted face; there is no \
660             hinge to turn about"
661        );
662    }
663    // Chained end to end into one run, closed or open, by where the ends
664    // are in space; across a seam the chart says two things and space one.
665    // Two runs is a face the plane crosses twice, which has no one hinge.
666    // A rim's segments come once per triangle on either side of it, so a
667    // segment already covered by the chain is dropped rather than chained.
668    let same = |a: Point, b: Point| a.distance(b) <= on;
669    let mut chain: Vec<((f64, f64), Point)> = vec![segments[0][0], segments[0][1]];
670    let mut used = vec![false; segments.len()];
671    used[0] = true;
672    loop {
673        let mut grew = false;
674        for (k, seg) in segments.iter().enumerate() {
675            if used[k] {
676                continue;
677            }
678            // The ends as they stand now: a pass grows the chain segment by
679            // segment, and the one closing it may come after the segments
680            // that brought its ends to the head and tail.
681            let tail = chain[chain.len() - 1].1;
682            let head = chain[0].1;
683            // The segment that joins the tail back to the head closes the
684            // run; it is kept, and the run is closed by it.
685            if (same(seg[0].1, tail) && same(seg[1].1, head))
686                || (same(seg[1].1, tail) && same(seg[0].1, head))
687            {
688                chain.push(chain[0]);
689                used[k] = true;
690                grew = true;
691                break;
692            }
693            let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
694            if covered(seg[0].1) && covered(seg[1].1) {
695                used[k] = true;
696                continue;
697            }
698            if same(seg[0].1, tail) {
699                chain.push(seg[1]);
700            } else if same(seg[1].1, tail) {
701                chain.push(seg[0]);
702            } else if same(seg[0].1, head) {
703                chain.insert(0, seg[1]);
704            } else if same(seg[1].1, head) {
705                chain.insert(0, seg[0]);
706            } else {
707                continue;
708            }
709            used[k] = true;
710            grew = true;
711        }
712        if !grew {
713            break;
714        }
715    }
716    if used.iter().any(|u| !u) {
717        ogeom_bail!(
718            Construction,
719            "the neutral plane crosses the drafted face more than once; \
720             there is no one hinge to turn about"
721        );
722    }
723    let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
724    if closed {
725        chain.pop();
726    }
727    if chain.len() < 2 {
728        ogeom_bail!(
729            Construction,
730            "the neutral plane touches the drafted face at a point; there is \
731             no hinge to turn about"
732        );
733    }
734    let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
735
736    // The mesh's stations are a chord apart; a cubic fitted through them
737    // sits a fraction of that chord off the true hinge. Resampled between
738    // them in the chart (across a periodic seam by the short way), and
739    // corrected onto the surface below, the stations are as dense as the
740    // fit's target wants.
741    let ((ua, ub), (va, vb)) = surface.domain();
742    // Closed counts as periodic here: a fitted tube closes on itself
743    // without repeating, and a chain crossing its join must still take
744    // the short way round.
745    let period = (
746        (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
747        (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
748    );
749    let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
750        let d = b - a;
751        match period {
752            Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
753            _ => d,
754        }
755    };
756    let chain: Vec<(f64, f64)> = {
757        // A closed hinge starts where it crosses the chart's own seam
758        // column, so the drafted support's seam stands where the old one
759        // did and the rebuild finds it there.
760        let chain: Vec<(f64, f64)> = if closed {
761            // The station exactly on the column: where the chain's segments
762            // cross `u = ua`, interpolated there; the nearest chain point
763            // otherwise.
764            let n = chain.len();
765            let mut exact: Option<(usize, (f64, f64))> = None;
766            for i in 0..n {
767                let (a, b) = (chain[i], chain[(i + 1) % n]);
768                // `b` is read as a step on from `a`: read on its own, the
769                // short way from the column flips sign half a period round
770                // as well, where the chain does not cross the column.
771                let da = short(ua, a.0, period.0);
772                let db = da + short(a.0, b.0, period.0);
773                if da == 0.0 {
774                    exact = Some((i, a));
775                    break;
776                }
777                if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
778                    let f = da / (da - db);
779                    let dv = short(a.1, b.1, period.1);
780                    exact = Some((i + 1, (ua, a.1 + dv * f)));
781                    break;
782                }
783            }
784            let (start, inserted) = exact.unwrap_or_else(|| {
785                let mut best = (0usize, f64::INFINITY);
786                for (i, c) in chain.iter().enumerate() {
787                    let d = short(ua, c.0, period.0).abs();
788                    if d < best.1 {
789                        best = (i, d);
790                    }
791                }
792                (best.0, chain[best.0])
793            });
794            let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
795            rotated.push(inserted);
796            for k in 0..n {
797                let c = chain[(start + k) % n];
798                if rotated.len() == 1 && c == inserted {
799                    continue;
800                }
801                rotated.push(c);
802            }
803            rotated
804        } else {
805            chain
806        };
807        // Evenly by arc length, so the fit's parameter is the hinge's own
808        // length: the mesh's segments run from a hair to a chord, and a
809        // cubic through stations that uneven wanders between them.
810        let pairs = if closed { chain.len() } else { chain.len() - 1 };
811        let mut lengths = Vec::with_capacity(pairs);
812        let mut total = 0.0;
813        for i in 0..pairs {
814            let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
815            let step = surface
816                .point_at(a.0, a.1, tol)?
817                .distance(surface.point_at(b.0, b.1, tol)?);
818            lengths.push(step);
819            total += step;
820        }
821        let count = if closed {
822            HINGE_STATIONS
823        } else {
824            HINGE_STATIONS + 1
825        };
826        let mut dense = Vec::with_capacity(count);
827        let (mut pair, mut walked) = (0usize, 0.0_f64);
828        for k in 0..count {
829            #[allow(clippy::cast_precision_loss)]
830            let target = total * k as f64 / HINGE_STATIONS as f64;
831            while pair + 1 < pairs && walked + lengths[pair] < target {
832                walked += lengths[pair];
833                pair += 1;
834            }
835            let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
836            let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
837            let f = if lengths[pair] > 0.0 {
838                ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
839            } else {
840                0.0
841            };
842            dense.push((a.0 + du * f, a.1 + dv * f));
843        }
844        dense
845    };
846
847    // Each station corrected onto the surface's crossing with the plane
848    // (the mesh's chord is not the surface), and read for its tangent and
849    // outward normal.
850    // A chart position corrected onto the crossing (along `v` alone where
851    // `pinned`), with the point there, the crossing's direction (either
852    // way along it) and the outward normal.
853    let on_hinge = |(mut u, mut v): (f64, f64),
854                    pinned: bool|
855     -> OgeomResult<((f64, f64), Point, Vector, Vector)> {
856        for _ in 0..8 {
857            let p = surface.point_at(u, v, tol)?;
858            let f = neutral.signed_distance_to(p);
859            if f.abs() <= tol.confusion() * 1e-2 {
860                break;
861            }
862            let (du, dv) = surface.d1_at(u, v, tol)?;
863            let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
864            let g2 = g.0 * g.0 + g.1 * g.1;
865            if g2 <= 0.0 {
866                break;
867            }
868            u -= f * g.0 / g2;
869            v -= f * g.1 / g2;
870        }
871        let p = surface.point_at(u, v, tol)?;
872        let (du, dv) = surface.d1_at(u, v, tol)?;
873        let raw = du.cross(dv);
874        if raw.magnitude() <= tol.confusion() {
875            ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
876        }
877        let outward = raw / raw.magnitude() * sign;
878        // Along the crossing: square to both normals.
879        let t = outward.cross(n);
880        if t.magnitude() <= tol.angular() {
881            ogeom_bail!(
882                Construction,
883                "the neutral plane is tangent to the drafted face; there is no \
884                 hinge to turn about"
885            );
886        }
887        Ok(((u, v), p, t / t.magnitude(), outward))
888    };
889    let mut feet: Vec<(f64, f64)> = Vec::with_capacity(chain.len());
890    let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
891    let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
892    let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
893    for (k, &at) in chain.iter().enumerate() {
894        // The first station of a closed hinge is the seam column's, and
895        // stays on it.
896        let (foot, p, mut t, outward) = on_hinge(at, closed && k == 0)?;
897        // Run the way the chain runs.
898        let next = chain[(k + 1) % chain.len()];
899        let prev = chain[(k + chain.len() - 1) % chain.len()];
900        let ahead =
901            surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
902        if t.dot(ahead) < 0.0 {
903            t = -t;
904        }
905        feet.push(foot);
906        hinges.push(p);
907        tangents.push(t);
908        outwards.push(outward);
909    }
910
911    // The sense, probed at the middle station as the other paths probe:
912    // the turn whose outward normal leans furthest towards the pull is the
913    // inward one, and the caller's sign picks inward or outward through it.
914    let m = hinges.len() / 2;
915    let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
916    let mut leaning = 1.0;
917    let mut best = f64::NEG_INFINITY;
918    for sense in [1.0_f64, -1.0] {
919        let turn = Transform::rotation(axis_m, angle.abs() * sense);
920        let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
921        if lean > best {
922            best = lean;
923            leaning = sense;
924        }
925    }
926    let theta = angle * leaning;
927
928    // One ruling per station: the pull turned about the hinge's tangent.
929    let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
930    for (hinge, tangent) in hinges.iter().zip(&tangents) {
931        let turn = Transform::rotation(
932            ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
933            theta,
934        );
935        rulings.push(turn.apply_vector(pull.vector()));
936    }
937    // How far the face reaches along the pull either side of its hinge,
938    // grown with the turn so the wall still reaches the neighbours it
939    // re-meets.
940    // Measured from every hinge station, not the middle one: an oblique
941    // hinge rises and falls along the pull, and a ruling from its low end
942    // must still reach the face's top.
943    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
944    let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
945    for h in &hinges {
946        let s = h.to_vector().dot(pull.vector());
947        h_lo = h_lo.min(s);
948        h_hi = h_hi.max(s);
949    }
950    for p in &mesh.positions {
951        let s = p.to_vector().dot(pull.vector());
952        s_lo = s_lo.min(s - h_hi);
953        s_hi = s_hi.max(s - h_lo);
954    }
955    let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
956    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
957    // How many stations continue an open hinge before its first.
958    let mut lead = 0;
959    if !closed {
960        // An open hinge is continued straight past both ends, for the
961        // same reason.
962        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| -> usize {
963            let (i0, i1) = if front {
964                (0, 1)
965            } else {
966                (hinges.len() - 1, hinges.len() - 2)
967            };
968            let d1 = hinges[i0] - hinges[i1];
969            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
970            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
971            let steps = (steps as usize).min(16);
972            for k in 1..=steps {
973                #[allow(clippy::cast_precision_loss)]
974                let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
975                if front {
976                    hinges.insert(0, station.0);
977                    rulings.insert(0, station.1);
978                } else {
979                    hinges.push(station.0);
980                    rulings.push(station.1);
981                }
982            }
983            steps
984        };
985        lead = extend(&mut hinges, &mut rulings, true);
986        extend(&mut hinges, &mut rulings, false);
987    } else {
988        hinges.push(hinges[0]);
989        rulings.push(rulings[0]);
990    }
991    for edge in [s_lo, s_hi] {
992        for i in 0..hinges.len() - 1 {
993            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
994            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
995                ogeom_bail!(
996                    Construction,
997                    "the draft folds the wall onto itself inside the drafted \
998                     window; refused; see docs/PARITY.md, offset.draft"
999                );
1000            }
1001        }
1002    }
1003    // The ruled surface itself, exactly: the wall's two border rows, at the
1004    // window's two heights along the rulings, fitted as cubics on one knot
1005    // vector at the *same* parameters and checked between the stations,
1006    // and the surface linear between them: degree one along the ruling, so
1007    // a straight line is a straight line, and every point of the window
1008    // stands between two rows each within the target. A grid fit through
1009    // rows at several heights parameterizes each row by its own chord and
1010    // averages, and rows that converge along their rulings disagree by
1011    // enough for a cubic across them to wander.
1012    // Chord-length parameters, not centripetal: the stations are as evenly
1013    // spaced as the mesh's segments let them be, and centripetal
1014    // parameters kink wherever the spacing changes, which a cubic then
1015    // cannot follow. By chord the parameter is the arc length whatever
1016    // the spacing.
1017    let params: Vec<f64> = {
1018        let mut out = Vec::with_capacity(hinges.len());
1019        let mut total = 0.0;
1020        out.push(0.0);
1021        for pair in hinges.windows(2) {
1022            total += pair[0].distance(pair[1]);
1023            out.push(total);
1024        }
1025        if total > 0.0 {
1026            for t in &mut out {
1027                *t /= total;
1028            }
1029        }
1030        if let Some(last) = out.last_mut() {
1031            *last = 1.0;
1032        }
1033        out
1034    };
1035    // The hinge and its ruling anywhere along it: a station's own, and
1036    // between two stations the crossing found again from the chart between
1037    // their feet, its ruling turned there as at a station; along an open
1038    // hinge's straight continuation, the line and its end's ruling.
1039    let stations = feet.len();
1040    let wrap = |x: f64, lo: f64, period: Option<f64>| -> f64 {
1041        period.map_or(x, |p| lo + (x - lo).rem_euclid(p))
1042    };
1043    let exact_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1044        let i = params
1045            .partition_point(|p| *p <= at)
1046            .saturating_sub(1)
1047            .min(params.len() - 2);
1048        let f = (at - params[i]) / (params[i + 1] - params[i]);
1049        if f <= 0.0 {
1050            return Ok((hinges[i], rulings[i]));
1051        }
1052        let (k0, k1) = (i.wrapping_sub(lead), (i + 1).wrapping_sub(lead));
1053        if k0 >= stations || k1 > stations || (!closed && k1 == stations) {
1054            return Ok((hinges[i] + (hinges[i + 1] - hinges[i]) * f, rulings[i]));
1055        }
1056        let (a, b) = (feet[k0], feet[k1 % stations]);
1057        let (mut u, mut v) = (
1058            wrap(a.0 + short(a.0, b.0, period.0) * f, ua, period.0),
1059            wrap(a.1 + short(a.1, b.1, period.1) * f, va, period.1),
1060        );
1061        // On the plane, and as far along the chord between the two
1062        // stations as the parameter is between theirs: a correction onto
1063        // the plane alone would slide along the hinge as it went, and the
1064        // hinge's pace would kink at every station.
1065        let (from, chord) = (hinges[i], hinges[i + 1] - hinges[i]);
1066        for _ in 0..8 {
1067            let q = surface.point_at(u, v, tol)?;
1068            let g = (
1069                neutral.signed_distance_to(q),
1070                (q - from).dot(chord) - f * chord.dot(chord),
1071            );
1072            if g.0.abs() <= tol.confusion() * 1e-2
1073                && g.1.abs() <= tol.confusion() * 1e-2 * chord.magnitude()
1074            {
1075                break;
1076            }
1077            let (du, dv) = surface.d1_at(u, v, tol)?;
1078            let (a11, a12, a21, a22) = (n.dot(du), n.dot(dv), chord.dot(du), chord.dot(dv));
1079            let det = a11 * a22 - a12 * a21;
1080            if det.abs() <= f64::MIN_POSITIVE {
1081                break;
1082            }
1083            u = wrap(u - (g.0 * a22 - a12 * g.1) / det, ua, period.0);
1084            v = wrap(v - (a11 * g.1 - a21 * g.0) / det, va, period.1);
1085        }
1086        let (_, p, t, _) = on_hinge((u, v), false)?;
1087        let t = if t.dot(tangents[k0]) < 0.0 { -t } else { t };
1088        let turn = Transform::rotation(ogeom_math::Axis::new(p, Direction::new(t, tol)?), theta);
1089        Ok((p, turn.apply_vector(pull.vector())))
1090    };
1091    // A closed hinge leaves its first station and comes back to it on the
1092    // two sides of the face's seam, where a face closed only to its
1093    // position turns its normal, and its rulings with it. The wall closes
1094    // on one ruling there, the mean of the two sides, and turns smoothly
1095    // from it to each side's own over a thirty-second of the way round
1096    // either side: a turn within one station would ask the fit for a knot
1097    // at every sample there. Each side continued past the seam on its
1098    // tangent plane meets the other near the mean ruling (on it, to first
1099    // order, where the hinge's tangent is square to the pull), so the
1100    // blend keeps the wall on the drafted surface; it is each side's last
1101    // ruling the wall leaves, by up to half the turn times the reach.
1102    const SEAM_BLEND: f64 = 1.0 / 32.0;
1103    let seam = if closed {
1104        let leave = exact_at(params[1] * 1e-9)?.1;
1105        let back = exact_at(1.0 - (1.0 - params[params.len() - 2]) * 1e-9)?.1;
1106        let mean = leave + back;
1107        Some((leave, back, mean / mean.magnitude()))
1108    } else {
1109        None
1110    };
1111    let ruled_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1112        let Some((leave, back, mean)) = seam else {
1113            return exact_at(at);
1114        };
1115        if at <= 0.0 || at >= 1.0 {
1116            return Ok((hinges[0], mean));
1117        }
1118        let (p, r) = exact_at(at)?;
1119        // The share of the turn left: one at the seam, none past the blend,
1120        // flat at both ends.
1121        let left = |x: f64| {
1122            let x = x.clamp(0.0, 1.0);
1123            1.0 - x * x * 2.0f64.mul_add(-x, 3.0)
1124        };
1125        let r = if at < SEAM_BLEND {
1126            r + (mean - leave) * left(at / SEAM_BLEND)
1127        } else if at > 1.0 - SEAM_BLEND {
1128            r + (mean - back) * left((1.0 - at) / SEAM_BLEND)
1129        } else {
1130            return Ok((p, r));
1131        };
1132        Ok((p, r / r.magnitude()))
1133    };
1134    // Both rows on one knot vector, refined where either strays between
1135    // the samples: a surface fit through the two rows, which is linear
1136    // across them.
1137    let fit_target = (tol.confusion() * 1e3).max(1e-4);
1138    let rows = ogeom_geom::fit::fit_surface_sampled(
1139        |at, side| {
1140            let (h, r) = ruled_at(at)?;
1141            Ok(h + r * if side < 0.5 { s_lo } else { s_hi })
1142        },
1143        &ogeom_geom::fit::Sampling {
1144            us: params.clone(),
1145            vs: vec![0.0, 1.0],
1146            between: (true, false),
1147            closed_v: false,
1148            most: 1024,
1149        },
1150        3,
1151        fit_target,
1152        tol,
1153    )?;
1154    if !rows.met {
1155        ogeom_bail!(
1156            NotDone,
1157            "the drafted wall's border rows reached {} against a target of {fit_target}",
1158            rows.error
1159        );
1160    }
1161    let fitted = rows.curve;
1162    let (k, l) = (fitted.grid().u_count(), fitted.grid().v_count());
1163    if l != 2 || fitted.v_knots().degree() != 1 {
1164        ogeom_bail!(
1165            Construction,
1166            "the drafted wall's border rows did not fit as a ruled surface"
1167        );
1168    }
1169    let corners: Vec<Point> = fitted.grid().points().iter().map(|w| w.point()).collect();
1170    let (lc, hc): (Vec<Point>, Vec<Point>) =
1171        (0..k).map(|i| (corners[i * 2], corners[i * 2 + 1])).unzip();
1172    // The face keeps its orientation flag, so the new surface's own normal
1173    // must turn the way the old one's did; whether it does depends on which
1174    // way the hinge chained. Measured at the hinge's middle, where the two
1175    // surfaces meet, and the rulings run from the far end back if not.
1176    let mid = {
1177        let (ud, _) = fitted.domain();
1178        f64::midpoint(ud.0, ud.1)
1179    };
1180    let (low_mid, high_mid) = (
1181        fitted.point_at(mid, 0.0, tol)?,
1182        fitted.point_at(mid, 1.0, tol)?,
1183    );
1184    let hinge_mid = low_mid + (high_mid - low_mid) * (-s_lo / (s_hi - s_lo));
1185    let across = fitted.d1_at(mid, 0.0, tol)?.0;
1186    let old_normal = {
1187        let foot = ogeom_algo::project_on_surface(surface, hinge_mid, 16, tol)?;
1188        let (u, v) = foot.parameters;
1189        surface.normal_at(u, v, tol)?.vector()
1190    };
1191    // du x dv of the new surface at the hinge: along the hinge, crossed
1192    // with up the ruling.
1193    let agrees = across.cross(high_mid - low_mid).dot(old_normal) >= 0.0;
1194    let (first, second, v_range) = if agrees {
1195        (&lc, &hc, (s_lo, s_hi))
1196    } else {
1197        (&hc, &lc, (-s_hi, -s_lo))
1198    };
1199    let mut net: Vec<Point> = Vec::with_capacity(k * 2);
1200    for (a, b) in first.iter().zip(second) {
1201        net.push(*a);
1202        net.push(*b);
1203    }
1204    let grid = ogeom_math::ControlGrid::new(net, k, 2)?;
1205    // The chart: `u` over the old surface's own `u` domain for a closed
1206    // hinge, so the seam column carries over; `v` the height along the
1207    // ruling in the model's own units.
1208    let u_knots = if closed {
1209        fitted.u_knots().reparameterized(ua, ub)?
1210    } else {
1211        fitted.u_knots().clone()
1212    };
1213    let v_knots =
1214        ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(v_range.0, v_range.1)?;
1215    Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
1216}
1217
1218/// Which sign turns a surface's raw normal (du x dv) outward, read from
1219/// the solid itself: probed a step off the face midpoint on both sides, at
1220/// growing steps until one side is material and the other is not.
1221///
1222/// # Errors
1223///
1224/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if no
1225/// probe separates the sides: a wall thinner than the probe can resolve.
1226fn outward_sign(
1227    model: &Model,
1228    solid: &Shape,
1229    face: &Shape,
1230    surface: &SurfaceGeometry,
1231    tol: Tolerances,
1232) -> OgeomResult<f64> {
1233    use ogeom_algo::Containment;
1234    // A point genuinely on the face (the surface's domain midpoint may lie
1235    // outside the trim), from the face's own triangulation, at its largest
1236    // triangle's centre.
1237    let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1238    let mut at = None;
1239    let mut largest = 0.0_f64;
1240    for t in &mesh.triangles {
1241        let [a, b, c] = [
1242            mesh.positions[t[0] as usize],
1243            mesh.positions[t[1] as usize],
1244            mesh.positions[t[2] as usize],
1245        ];
1246        let area = (b - a).cross(c - a).magnitude();
1247        if area > largest {
1248            largest = area;
1249            let params = [
1250                mesh.parameters[t[0] as usize],
1251                mesh.parameters[t[1] as usize],
1252                mesh.parameters[t[2] as usize],
1253            ];
1254            at = Some((
1255                (params[0].0 + params[1].0 + params[2].0) / 3.0,
1256                (params[0].1 + params[1].1 + params[2].1) / 3.0,
1257            ));
1258        }
1259    }
1260    let Some((um, vm)) = at else {
1261        ogeom_bail!(Construction, "the drafted face has no interior to probe");
1262    };
1263    let p = surface.point_at(um, vm, tol)?;
1264    let (du, dv) = surface.d1_at(um, vm, tol)?;
1265    let n = du.cross(dv);
1266    let m = n.magnitude();
1267    if m <= tol.confusion() {
1268        ogeom_bail!(Construction, "the face has no normal at its midpoint");
1269    }
1270    let n = n / m;
1271    let scale = largest.sqrt().max(tol.confusion() * 1e3);
1272    for eps_scale in [1e-3, 1e-2, 5e-2] {
1273        let eps = scale * eps_scale;
1274        let deflection = ogeom_mesh::Deflection {
1275            chord: (eps * 0.1).max(1e-4),
1276            ..ogeom_mesh::Deflection::default()
1277        };
1278        let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1279        let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1280        match (ahead, behind) {
1281            (Containment::Out, Containment::In) => return Ok(1.0),
1282            (Containment::In, Containment::Out) => return Ok(-1.0),
1283            _ => {}
1284        }
1285    }
1286    ogeom_bail!(
1287        Construction,
1288        "cannot read which side of the drafted face holds material; the          wall is thinner than the probe can resolve"
1289    )
1290}
1291
1292/// A point on the line where two planes meet, nearest their origins.
1293fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1294    let rows = [a.normal().vector(), b.normal().vector(), along];
1295    let rhs = [
1296        rows[0].dot(a.origin().to_vector()),
1297        rows[1].dot(b.origin().to_vector()),
1298        along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1299    ];
1300    let det = rows[0].dot(rows[1].cross(rows[2]));
1301    if det.abs() <= tol.confusion() {
1302        ogeom_bail!(Construction, "the two planes do not meet in a line");
1303    }
1304    Ok(Point::ORIGIN
1305        + (rows[1].cross(rows[2]) * rhs[0]
1306            + rows[2].cross(rows[0]) * rhs[1]
1307            + rows[0].cross(rows[1]) * rhs[2])
1308            / det)
1309}