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 tail = chain[chain.len() - 1].1;
674        let head = chain[0].1;
675        let mut grew = false;
676        for (k, seg) in segments.iter().enumerate() {
677            if used[k] {
678                continue;
679            }
680            // The segment that joins the tail back to the head closes the
681            // run; it is kept, and the run is closed by it.
682            if (same(seg[0].1, tail) && same(seg[1].1, head))
683                || (same(seg[1].1, tail) && same(seg[0].1, head))
684            {
685                chain.push(chain[0]);
686                used[k] = true;
687                grew = true;
688                break;
689            }
690            let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
691            if covered(seg[0].1) && covered(seg[1].1) {
692                used[k] = true;
693                continue;
694            }
695            if same(seg[0].1, tail) {
696                chain.push(seg[1]);
697            } else if same(seg[1].1, tail) {
698                chain.push(seg[0]);
699            } else if same(seg[0].1, head) {
700                chain.insert(0, seg[1]);
701            } else if same(seg[1].1, head) {
702                chain.insert(0, seg[0]);
703            } else {
704                continue;
705            }
706            used[k] = true;
707            grew = true;
708        }
709        if !grew {
710            break;
711        }
712    }
713    if used.iter().any(|u| !u) {
714        ogeom_bail!(
715            Construction,
716            "the neutral plane crosses the drafted face more than once; \
717             there is no one hinge to turn about"
718        );
719    }
720    let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
721    if closed {
722        chain.pop();
723    }
724    if chain.len() < 2 {
725        ogeom_bail!(
726            Construction,
727            "the neutral plane touches the drafted face at a point; there is \
728             no hinge to turn about"
729        );
730    }
731    let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
732
733    // The mesh's stations are a chord apart; a cubic fitted through them
734    // sits a fraction of that chord off the true hinge. Resampled between
735    // them in the chart (across a periodic seam by the short way), and
736    // corrected onto the surface below, the stations are as dense as the
737    // fit's target wants.
738    let ((ua, ub), (va, vb)) = surface.domain();
739    // Closed counts as periodic here: a fitted tube closes on itself
740    // without repeating, and a chain crossing its join must still take
741    // the short way round.
742    let period = (
743        (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
744        (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
745    );
746    let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
747        let d = b - a;
748        match period {
749            Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
750            _ => d,
751        }
752    };
753    let chain: Vec<(f64, f64)> = {
754        // A closed hinge starts where it crosses the chart's own seam
755        // column, so the drafted support's seam stands where the old one
756        // did and the rebuild finds it there.
757        let chain: Vec<(f64, f64)> = if closed {
758            // The station exactly on the column: where the chain's segments
759            // cross `u = ua`, interpolated there; the nearest chain point
760            // otherwise.
761            let n = chain.len();
762            let mut exact: Option<(usize, (f64, f64))> = None;
763            for i in 0..n {
764                let (a, b) = (chain[i], chain[(i + 1) % n]);
765                let (da, db) = (short(ua, a.0, period.0), short(ua, b.0, period.0));
766                if da == 0.0 {
767                    exact = Some((i, a));
768                    break;
769                }
770                if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
771                    let f = da / (da - db);
772                    let dv = short(a.1, b.1, period.1);
773                    exact = Some((i + 1, (ua, a.1 + dv * f)));
774                    break;
775                }
776            }
777            let (start, inserted) = exact.unwrap_or_else(|| {
778                let mut best = (0usize, f64::INFINITY);
779                for (i, c) in chain.iter().enumerate() {
780                    let d = short(ua, c.0, period.0).abs();
781                    if d < best.1 {
782                        best = (i, d);
783                    }
784                }
785                (best.0, chain[best.0])
786            });
787            let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
788            rotated.push(inserted);
789            for k in 0..n {
790                let c = chain[(start + k) % n];
791                if rotated.len() == 1 && c == inserted {
792                    continue;
793                }
794                rotated.push(c);
795            }
796            rotated
797        } else {
798            chain
799        };
800        // Evenly by arc length, so the fit's parameter is the hinge's own
801        // length: the mesh's segments run from a hair to a chord, and a
802        // cubic through stations that uneven wanders between them.
803        let pairs = if closed { chain.len() } else { chain.len() - 1 };
804        let mut lengths = Vec::with_capacity(pairs);
805        let mut total = 0.0;
806        for i in 0..pairs {
807            let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
808            let step = surface
809                .point_at(a.0, a.1, tol)?
810                .distance(surface.point_at(b.0, b.1, tol)?);
811            lengths.push(step);
812            total += step;
813        }
814        let count = if closed {
815            HINGE_STATIONS
816        } else {
817            HINGE_STATIONS + 1
818        };
819        let mut dense = Vec::with_capacity(count);
820        let (mut pair, mut walked) = (0usize, 0.0_f64);
821        for k in 0..count {
822            #[allow(clippy::cast_precision_loss)]
823            let target = total * k as f64 / HINGE_STATIONS as f64;
824            while pair + 1 < pairs && walked + lengths[pair] < target {
825                walked += lengths[pair];
826                pair += 1;
827            }
828            let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
829            let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
830            let f = if lengths[pair] > 0.0 {
831                ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
832            } else {
833                0.0
834            };
835            dense.push((a.0 + du * f, a.1 + dv * f));
836        }
837        dense
838    };
839
840    // Each station corrected onto the surface's crossing with the plane
841    // (the mesh's chord is not the surface), and read for its tangent and
842    // outward normal.
843    // A chart position corrected onto the crossing (along `v` alone where
844    // `pinned`), with the point there, the crossing's direction (either
845    // way along it) and the outward normal.
846    let on_hinge = |(mut u, mut v): (f64, f64),
847                    pinned: bool|
848     -> OgeomResult<((f64, f64), Point, Vector, Vector)> {
849        for _ in 0..8 {
850            let p = surface.point_at(u, v, tol)?;
851            let f = neutral.signed_distance_to(p);
852            if f.abs() <= tol.confusion() * 1e-2 {
853                break;
854            }
855            let (du, dv) = surface.d1_at(u, v, tol)?;
856            let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
857            let g2 = g.0 * g.0 + g.1 * g.1;
858            if g2 <= 0.0 {
859                break;
860            }
861            u -= f * g.0 / g2;
862            v -= f * g.1 / g2;
863        }
864        let p = surface.point_at(u, v, tol)?;
865        let (du, dv) = surface.d1_at(u, v, tol)?;
866        let raw = du.cross(dv);
867        if raw.magnitude() <= tol.confusion() {
868            ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
869        }
870        let outward = raw / raw.magnitude() * sign;
871        // Along the crossing: square to both normals.
872        let t = outward.cross(n);
873        if t.magnitude() <= tol.angular() {
874            ogeom_bail!(
875                Construction,
876                "the neutral plane is tangent to the drafted face; there is no \
877                 hinge to turn about"
878            );
879        }
880        Ok(((u, v), p, t / t.magnitude(), outward))
881    };
882    let mut feet: Vec<(f64, f64)> = Vec::with_capacity(chain.len());
883    let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
884    let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
885    let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
886    for (k, &at) in chain.iter().enumerate() {
887        // The first station of a closed hinge is the seam column's, and
888        // stays on it.
889        let (foot, p, mut t, outward) = on_hinge(at, closed && k == 0)?;
890        // Run the way the chain runs.
891        let next = chain[(k + 1) % chain.len()];
892        let prev = chain[(k + chain.len() - 1) % chain.len()];
893        let ahead =
894            surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
895        if t.dot(ahead) < 0.0 {
896            t = -t;
897        }
898        feet.push(foot);
899        hinges.push(p);
900        tangents.push(t);
901        outwards.push(outward);
902    }
903
904    // The sense, probed at the middle station as the other paths probe:
905    // the turn whose outward normal leans furthest towards the pull is the
906    // inward one, and the caller's sign picks inward or outward through it.
907    let m = hinges.len() / 2;
908    let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
909    let mut leaning = 1.0;
910    let mut best = f64::NEG_INFINITY;
911    for sense in [1.0_f64, -1.0] {
912        let turn = Transform::rotation(axis_m, angle.abs() * sense);
913        let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
914        if lean > best {
915            best = lean;
916            leaning = sense;
917        }
918    }
919    let theta = angle * leaning;
920
921    // One ruling per station: the pull turned about the hinge's tangent.
922    let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
923    for (hinge, tangent) in hinges.iter().zip(&tangents) {
924        let turn = Transform::rotation(
925            ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
926            theta,
927        );
928        rulings.push(turn.apply_vector(pull.vector()));
929    }
930    // How far the face reaches along the pull either side of its hinge,
931    // grown with the turn so the wall still reaches the neighbours it
932    // re-meets.
933    // Measured from every hinge station, not the middle one: an oblique
934    // hinge rises and falls along the pull, and a ruling from its low end
935    // must still reach the face's top.
936    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
937    let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
938    for h in &hinges {
939        let s = h.to_vector().dot(pull.vector());
940        h_lo = h_lo.min(s);
941        h_hi = h_hi.max(s);
942    }
943    for p in &mesh.positions {
944        let s = p.to_vector().dot(pull.vector());
945        s_lo = s_lo.min(s - h_hi);
946        s_hi = s_hi.max(s - h_lo);
947    }
948    let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
949    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
950    // How many stations continue an open hinge before its first.
951    let mut lead = 0;
952    if !closed {
953        // An open hinge is continued straight past both ends, for the
954        // same reason.
955        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| -> usize {
956            let (i0, i1) = if front {
957                (0, 1)
958            } else {
959                (hinges.len() - 1, hinges.len() - 2)
960            };
961            let d1 = hinges[i0] - hinges[i1];
962            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
963            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
964            let steps = (steps as usize).min(16);
965            for k in 1..=steps {
966                #[allow(clippy::cast_precision_loss)]
967                let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
968                if front {
969                    hinges.insert(0, station.0);
970                    rulings.insert(0, station.1);
971                } else {
972                    hinges.push(station.0);
973                    rulings.push(station.1);
974                }
975            }
976            steps
977        };
978        lead = extend(&mut hinges, &mut rulings, true);
979        extend(&mut hinges, &mut rulings, false);
980    } else {
981        hinges.push(hinges[0]);
982        rulings.push(rulings[0]);
983    }
984    for edge in [s_lo, s_hi] {
985        for i in 0..hinges.len() - 1 {
986            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
987            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
988                ogeom_bail!(
989                    Construction,
990                    "the draft folds the wall onto itself inside the drafted \
991                     window; refused; see docs/PARITY.md, offset.draft"
992                );
993            }
994        }
995    }
996    // The ruled surface itself, exactly: the wall's two border rows, at the
997    // window's two heights along the rulings, fitted as cubics on one knot
998    // vector at the *same* parameters and checked between the stations,
999    // and the surface linear between them: degree one along the ruling, so
1000    // a straight line is a straight line, and every point of the window
1001    // stands between two rows each within the target. A grid fit through
1002    // rows at several heights parameterizes each row by its own chord and
1003    // averages, and rows that converge along their rulings disagree by
1004    // enough for a cubic across them to wander.
1005    // Chord-length parameters, not centripetal: the stations are as evenly
1006    // spaced as the mesh's segments let them be, and centripetal
1007    // parameters kink wherever the spacing changes, which a cubic then
1008    // cannot follow. By chord the parameter is the arc length whatever
1009    // the spacing.
1010    let params: Vec<f64> = {
1011        let mut out = Vec::with_capacity(hinges.len());
1012        let mut total = 0.0;
1013        out.push(0.0);
1014        for pair in hinges.windows(2) {
1015            total += pair[0].distance(pair[1]);
1016            out.push(total);
1017        }
1018        if total > 0.0 {
1019            for t in &mut out {
1020                *t /= total;
1021            }
1022        }
1023        if let Some(last) = out.last_mut() {
1024            *last = 1.0;
1025        }
1026        out
1027    };
1028    // The hinge and its ruling anywhere along it: a station's own, and
1029    // between two stations the crossing found again from the chart between
1030    // their feet, its ruling turned there as at a station; along an open
1031    // hinge's straight continuation, the line and its end's ruling.
1032    let stations = feet.len();
1033    let wrap = |x: f64, lo: f64, period: Option<f64>| -> f64 {
1034        period.map_or(x, |p| lo + (x - lo).rem_euclid(p))
1035    };
1036    let exact_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1037        let i = params
1038            .partition_point(|p| *p <= at)
1039            .saturating_sub(1)
1040            .min(params.len() - 2);
1041        let f = (at - params[i]) / (params[i + 1] - params[i]);
1042        if f <= 0.0 {
1043            return Ok((hinges[i], rulings[i]));
1044        }
1045        let (k0, k1) = (i.wrapping_sub(lead), (i + 1).wrapping_sub(lead));
1046        if k0 >= stations || k1 > stations || (!closed && k1 == stations) {
1047            return Ok((hinges[i] + (hinges[i + 1] - hinges[i]) * f, rulings[i]));
1048        }
1049        let (a, b) = (feet[k0], feet[k1 % stations]);
1050        let (mut u, mut v) = (
1051            wrap(a.0 + short(a.0, b.0, period.0) * f, ua, period.0),
1052            wrap(a.1 + short(a.1, b.1, period.1) * f, va, period.1),
1053        );
1054        // On the plane, and as far along the chord between the two
1055        // stations as the parameter is between theirs: a correction onto
1056        // the plane alone would slide along the hinge as it went, and the
1057        // hinge's pace would kink at every station.
1058        let (from, chord) = (hinges[i], hinges[i + 1] - hinges[i]);
1059        for _ in 0..8 {
1060            let q = surface.point_at(u, v, tol)?;
1061            let g = (
1062                neutral.signed_distance_to(q),
1063                (q - from).dot(chord) - f * chord.dot(chord),
1064            );
1065            if g.0.abs() <= tol.confusion() * 1e-2
1066                && g.1.abs() <= tol.confusion() * 1e-2 * chord.magnitude()
1067            {
1068                break;
1069            }
1070            let (du, dv) = surface.d1_at(u, v, tol)?;
1071            let (a11, a12, a21, a22) = (n.dot(du), n.dot(dv), chord.dot(du), chord.dot(dv));
1072            let det = a11 * a22 - a12 * a21;
1073            if det.abs() <= f64::MIN_POSITIVE {
1074                break;
1075            }
1076            u = wrap(u - (g.0 * a22 - a12 * g.1) / det, ua, period.0);
1077            v = wrap(v - (a11 * g.1 - a21 * g.0) / det, va, period.1);
1078        }
1079        let (_, p, t, _) = on_hinge((u, v), false)?;
1080        let t = if t.dot(tangents[k0]) < 0.0 { -t } else { t };
1081        let turn = Transform::rotation(ogeom_math::Axis::new(p, Direction::new(t, tol)?), theta);
1082        Ok((p, turn.apply_vector(pull.vector())))
1083    };
1084    // A closed hinge leaves its first station and comes back to it on the
1085    // two sides of the face's seam, where a face closed only to its
1086    // position turns its normal, and its rulings with it. The wall closes
1087    // on one ruling there, the mean of the two sides, and turns smoothly
1088    // from it to each side's own over a thirty-second of the way round
1089    // either side: a turn within one station would ask the fit for a knot
1090    // at every sample there.
1091    const SEAM_BLEND: f64 = 1.0 / 32.0;
1092    let seam = if closed {
1093        let leave = exact_at(params[1] * 1e-9)?.1;
1094        let back = exact_at(1.0 - (1.0 - params[params.len() - 2]) * 1e-9)?.1;
1095        let mean = leave + back;
1096        Some((leave, back, mean / mean.magnitude()))
1097    } else {
1098        None
1099    };
1100    let ruled_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1101        let Some((leave, back, mean)) = seam else {
1102            return exact_at(at);
1103        };
1104        if at <= 0.0 || at >= 1.0 {
1105            return Ok((hinges[0], mean));
1106        }
1107        let (p, r) = exact_at(at)?;
1108        // The share of the turn left: one at the seam, none past the blend,
1109        // flat at both ends.
1110        let left = |x: f64| {
1111            let x = x.clamp(0.0, 1.0);
1112            1.0 - x * x * 2.0f64.mul_add(-x, 3.0)
1113        };
1114        let r = if at < SEAM_BLEND {
1115            r + (mean - leave) * left(at / SEAM_BLEND)
1116        } else if at > 1.0 - SEAM_BLEND {
1117            r + (mean - back) * left((1.0 - at) / SEAM_BLEND)
1118        } else {
1119            return Ok((p, r));
1120        };
1121        Ok((p, r / r.magnitude()))
1122    };
1123    // Both rows on one knot vector, refined where either strays between
1124    // the samples: a surface fit through the two rows, which is linear
1125    // across them.
1126    let fit_target = (tol.confusion() * 1e3).max(1e-4);
1127    let rows = ogeom_geom::fit::fit_surface_sampled(
1128        |at, side| {
1129            let (h, r) = ruled_at(at)?;
1130            Ok(h + r * if side < 0.5 { s_lo } else { s_hi })
1131        },
1132        &ogeom_geom::fit::Sampling {
1133            us: params.clone(),
1134            vs: vec![0.0, 1.0],
1135            between: (true, false),
1136            closed_v: false,
1137            most: 1024,
1138        },
1139        3,
1140        fit_target,
1141        tol,
1142    )?;
1143    if !rows.met {
1144        ogeom_bail!(
1145            NotDone,
1146            "the drafted wall's border rows reached {} against a target of {fit_target}",
1147            rows.error
1148        );
1149    }
1150    let fitted = rows.curve;
1151    let (k, l) = (fitted.grid().u_count(), fitted.grid().v_count());
1152    if l != 2 || fitted.v_knots().degree() != 1 {
1153        ogeom_bail!(
1154            Construction,
1155            "the drafted wall's border rows did not fit as a ruled surface"
1156        );
1157    }
1158    let corners: Vec<Point> = fitted.grid().points().iter().map(|w| w.point()).collect();
1159    let (lc, hc): (Vec<Point>, Vec<Point>) =
1160        (0..k).map(|i| (corners[i * 2], corners[i * 2 + 1])).unzip();
1161    // The face keeps its orientation flag, so the new surface's own normal
1162    // must turn the way the old one's did; whether it does depends on which
1163    // way the hinge chained. Measured at the hinge's middle, where the two
1164    // surfaces meet, and the rulings run from the far end back if not.
1165    let mid = {
1166        let (ud, _) = fitted.domain();
1167        f64::midpoint(ud.0, ud.1)
1168    };
1169    let (low_mid, high_mid) = (
1170        fitted.point_at(mid, 0.0, tol)?,
1171        fitted.point_at(mid, 1.0, tol)?,
1172    );
1173    let hinge_mid = low_mid + (high_mid - low_mid) * (-s_lo / (s_hi - s_lo));
1174    let across = fitted.d1_at(mid, 0.0, tol)?.0;
1175    let old_normal = {
1176        let foot = ogeom_algo::project_on_surface(surface, hinge_mid, 16, tol)?;
1177        let (u, v) = foot.parameters;
1178        surface.normal_at(u, v, tol)?.vector()
1179    };
1180    // du x dv of the new surface at the hinge: along the hinge, crossed
1181    // with up the ruling.
1182    let agrees = across.cross(high_mid - low_mid).dot(old_normal) >= 0.0;
1183    let (first, second, v_range) = if agrees {
1184        (&lc, &hc, (s_lo, s_hi))
1185    } else {
1186        (&hc, &lc, (-s_hi, -s_lo))
1187    };
1188    let mut net: Vec<Point> = Vec::with_capacity(k * 2);
1189    for (a, b) in first.iter().zip(second) {
1190        net.push(*a);
1191        net.push(*b);
1192    }
1193    let grid = ogeom_math::ControlGrid::new(net, k, 2)?;
1194    // The chart: `u` over the old surface's own `u` domain for a closed
1195    // hinge, so the seam column carries over; `v` the height along the
1196    // ruling in the model's own units.
1197    let u_knots = if closed {
1198        fitted.u_knots().reparameterized(ua, ub)?
1199    } else {
1200        fitted.u_knots().clone()
1201    };
1202    let v_knots =
1203        ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(v_range.0, v_range.1)?;
1204    Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
1205}
1206
1207/// Which sign turns a surface's raw normal (du x dv) outward, read from
1208/// the solid itself: probed a step off the face midpoint on both sides, at
1209/// growing steps until one side is material and the other is not.
1210///
1211/// # Errors
1212///
1213/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if no
1214/// probe separates the sides: a wall thinner than the probe can resolve.
1215fn outward_sign(
1216    model: &Model,
1217    solid: &Shape,
1218    face: &Shape,
1219    surface: &SurfaceGeometry,
1220    tol: Tolerances,
1221) -> OgeomResult<f64> {
1222    use ogeom_algo::Containment;
1223    // A point genuinely on the face (the surface's domain midpoint may lie
1224    // outside the trim), from the face's own triangulation, at its largest
1225    // triangle's centre.
1226    let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1227    let mut at = None;
1228    let mut largest = 0.0_f64;
1229    for t in &mesh.triangles {
1230        let [a, b, c] = [
1231            mesh.positions[t[0] as usize],
1232            mesh.positions[t[1] as usize],
1233            mesh.positions[t[2] as usize],
1234        ];
1235        let area = (b - a).cross(c - a).magnitude();
1236        if area > largest {
1237            largest = area;
1238            let params = [
1239                mesh.parameters[t[0] as usize],
1240                mesh.parameters[t[1] as usize],
1241                mesh.parameters[t[2] as usize],
1242            ];
1243            at = Some((
1244                (params[0].0 + params[1].0 + params[2].0) / 3.0,
1245                (params[0].1 + params[1].1 + params[2].1) / 3.0,
1246            ));
1247        }
1248    }
1249    let Some((um, vm)) = at else {
1250        ogeom_bail!(Construction, "the drafted face has no interior to probe");
1251    };
1252    let p = surface.point_at(um, vm, tol)?;
1253    let (du, dv) = surface.d1_at(um, vm, tol)?;
1254    let n = du.cross(dv);
1255    let m = n.magnitude();
1256    if m <= tol.confusion() {
1257        ogeom_bail!(Construction, "the face has no normal at its midpoint");
1258    }
1259    let n = n / m;
1260    let scale = largest.sqrt().max(tol.confusion() * 1e3);
1261    for eps_scale in [1e-3, 1e-2, 5e-2] {
1262        let eps = scale * eps_scale;
1263        let deflection = ogeom_mesh::Deflection {
1264            chord: (eps * 0.1).max(1e-4),
1265            ..ogeom_mesh::Deflection::default()
1266        };
1267        let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1268        let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1269        match (ahead, behind) {
1270            (Containment::Out, Containment::In) => return Ok(1.0),
1271            (Containment::In, Containment::Out) => return Ok(-1.0),
1272            _ => {}
1273        }
1274    }
1275    ogeom_bail!(
1276        Construction,
1277        "cannot read which side of the drafted face holds material; the          wall is thinner than the probe can resolve"
1278    )
1279}
1280
1281/// A point on the line where two planes meet, nearest their origins.
1282fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1283    let rows = [a.normal().vector(), b.normal().vector(), along];
1284    let rhs = [
1285        rows[0].dot(a.origin().to_vector()),
1286        rows[1].dot(b.origin().to_vector()),
1287        along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1288    ];
1289    let det = rows[0].dot(rows[1].cross(rows[2]));
1290    if det.abs() <= tol.confusion() {
1291        ogeom_bail!(Construction, "the two planes do not meet in a line");
1292    }
1293    Ok(Point::ORIGIN
1294        + (rows[1].cross(rows[2]) * rhs[0]
1295            + rows[2].cross(rows[0]) * rhs[1]
1296            + rows[0].cross(rows[1]) * rhs[2])
1297            / det)
1298}