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::{Curve3d as _, 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 sample: the hinge point, the hinge tangent, and the
394    // extrusion direction turned about it.
395    const ALONG: usize = 65;
396    let mut hinges: Vec<Point> = Vec::with_capacity(ALONG);
397    let mut rulings: Vec<Vector> = Vec::with_capacity(ALONG);
398    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
399    for i in 0..ALONG {
400        #[allow(clippy::cast_precision_loss)]
401        let u = u0 + (u1 - u0) * (i as f64) / ((ALONG - 1) as f64);
402        let c = curve.point_at(u, tol)?;
403        let cd = curve.d1_at(u, tol)?;
404        let h = height_at(c);
405        let hinge = c + d * h;
406        let tangent = Direction::new(hinge_tangent(cd), tol)?;
407        let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
408        hinges.push(hinge);
409        rulings.push(turn.apply_vector(d));
410        s_lo = s_lo.min(v0 - h);
411        s_hi = s_hi.max(v1 - h);
412    }
413    // The window grows with the turn, as the planar draft's does. Along the
414    // profile the curve itself ends, so the growth is a tangent-line
415    // continuation at each end: the wall must still reach the neighbours it
416    // re-meets, and the continuation exists only to be trimmed away there.
417    let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
418        + tol.confusion();
419    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
420    {
421        // Quadratic continuation, so the fitted wall keeps its end
422        // curvature across the join instead of kinking straight.
423        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
424            let (i0, i1, i2) = if front {
425                (0, 1, 2)
426            } else {
427                let n = hinges.len();
428                (n - 1, n - 2, n - 3)
429            };
430            let d1 = hinges[i0] - hinges[i1];
431            let d2 = (hinges[i0] - hinges[i1]) - (hinges[i1] - hinges[i2]);
432            let r1 = rulings[i0] - rulings[i1];
433            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
434            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
435            let steps = (steps as usize).min(16);
436            for k in 1..=steps {
437                #[allow(clippy::cast_precision_loss)]
438                let k = k as f64;
439                let station = (
440                    hinges[i0] + d1 * k + d2 * (k * (k + 1.0) / 2.0),
441                    rulings[i0] + r1 * k,
442                );
443                if front {
444                    hinges.insert(0, station.0);
445                    rulings.insert(0, station.1);
446                } else {
447                    hinges.push(station.0);
448                    rulings.push(station.1);
449                }
450            }
451        };
452        extend(&mut hinges, &mut rulings, true);
453        extend(&mut hinges, &mut rulings, false);
454    }
455    let along_total = hinges.len();
456
457    // A fold is two rulings crossing inside the window: walking the wall at
458    // either extreme height must still advance the way the hinge advances.
459    for edge in [s_lo, s_hi] {
460        for i in 0..along_total - 1 {
461            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
462            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
463                ogeom_bail!(
464                    Construction,
465                    "the draft folds the wall onto itself inside the drafted \
466                     window; refused; see docs/PARITY.md, offset.draft"
467                );
468            }
469        }
470    }
471
472    // Rulings are straight, so a handful of rows fits them exactly; the
473    // profile direction carries the shape.
474    const ACROSS: usize = 9;
475    let rows: Vec<Vec<Point>> = (0..ACROSS)
476        .map(|j| {
477            #[allow(clippy::cast_precision_loss)]
478            let s = s_lo + (s_hi - s_lo) * (j as f64) / ((ACROSS - 1) as f64);
479            (0..along_total)
480                .map(|i| hinges[i] + rulings[i] * s)
481                .collect()
482        })
483        .collect();
484    let fit_target = (tol.confusion() * 1e3).max(1e-4);
485    let fitted = ogeom_geom::fit::fit_surface_grid(&rows, 3, fit_target, tol)?;
486    if !fitted.met {
487        ogeom_bail!(
488            NotDone,
489            "the drafted wall's fit reached {} against a target of {fit_target}",
490            fitted.error
491        );
492    }
493    Ok(fitted.curve.into())
494}
495
496/// How many stations a general draft's hinge is sampled at, all round.
497const HINGE_STATIONS: usize = 256;
498
499/// The turned support for any face: the ruled surface through the face's
500/// crossing with the neutral plane, its rulings the pull direction turned
501/// about the crossing's tangent by the draft: what a mould-maker means by
502/// a draft, and what the planar and revolved paths are the closed forms
503/// of. The crossing is read off the face's own mesh and corrected onto the
504/// surface, so it lies inside the face whatever the surface's chart does.
505#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
506fn general_draft(
507    model: &Model,
508    face: &Shape,
509    surface: &SurfaceGeometry,
510    sign: f64,
511    neutral: Plane,
512    pull: Direction,
513    angle: f64,
514    tol: Tolerances,
515) -> OgeomResult<SurfaceGeometry> {
516    let n = neutral.normal().vector();
517    let mesh = ogeom_mesh::triangulate_face(
518        model,
519        face,
520        ogeom_mesh::Deflection {
521            chord: 0.05,
522            angular: 0.2,
523            ..ogeom_mesh::Deflection::default()
524        },
525        tol,
526    )?;
527    let extent = {
528        let b = mesh
529            .positions
530            .iter()
531            .fold(ogeom_math::Aabb::EMPTY, |acc, p| acc.with_point(*p));
532        match (b.low(), b.high()) {
533            (Some(lo), Some(hi)) => (hi - lo).magnitude(),
534            _ => ogeom_bail!(Construction, "the drafted face has no extent"),
535        }
536    };
537    if extent <= tol.confusion() {
538        ogeom_bail!(Construction, "the drafted face has no extent");
539    }
540
541    // The crossing, one segment per triangle the plane cuts, in the chart
542    // with its ends in space. A vertex the plane passes through (the hinge
543    // running along a rim, as a draft about a base does) is a crossing in
544    // itself, not a sign to read; read as one, rounding gives every rim
545    // triangle a hair of a segment pointing anywhere.
546    let on = tol.confusion() * 10.0;
547    let side: Vec<f64> = mesh
548        .positions
549        .iter()
550        .map(|p| neutral.signed_distance_to(*p))
551        .collect();
552    let mut segments: Vec<[((f64, f64), Point); 2]> = Vec::new();
553    for t in &mesh.triangles {
554        let mut ends: Vec<((f64, f64), Point)> = Vec::with_capacity(2);
555        for &corner in t {
556            let i = corner as usize;
557            if side[i].abs() <= on {
558                ends.push((mesh.parameters[i], mesh.positions[i]));
559            }
560        }
561        for k in 0..3 {
562            let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize);
563            let (a, b) = (side[i], side[j]);
564            if a.abs() <= on || b.abs() <= on || (a < 0.0) == (b < 0.0) {
565                continue;
566            }
567            let f = a / (a - b);
568            let (pa, pb) = (mesh.parameters[i], mesh.parameters[j]);
569            let (qa, qb) = (mesh.positions[i], mesh.positions[j]);
570            ends.push((
571                (pa.0 + (pb.0 - pa.0) * f, pa.1 + (pb.1 - pa.1) * f),
572                qa + (qb - qa) * f,
573            ));
574        }
575        ends.dedup_by(|a, b| a.1.distance(b.1) <= on);
576        if ends.len() == 2 && ends[0].1.distance(ends[1].1) > on {
577            segments.push([ends[0], ends[1]]);
578        }
579    }
580    if segments.is_empty() {
581        ogeom_bail!(
582            Construction,
583            "the neutral plane does not cross the drafted face; there is no \
584             hinge to turn about"
585        );
586    }
587    // Chained end to end into one run, closed or open, by where the ends
588    // are in space; across a seam the chart says two things and space one.
589    // Two runs is a face the plane crosses twice, which has no one hinge.
590    // A rim's segments come once per triangle on either side of it, so a
591    // segment already covered by the chain is dropped rather than chained.
592    let same = |a: Point, b: Point| a.distance(b) <= on;
593    let mut chain: Vec<((f64, f64), Point)> = vec![segments[0][0], segments[0][1]];
594    let mut used = vec![false; segments.len()];
595    used[0] = true;
596    loop {
597        let tail = chain[chain.len() - 1].1;
598        let head = chain[0].1;
599        let mut grew = false;
600        for (k, seg) in segments.iter().enumerate() {
601            if used[k] {
602                continue;
603            }
604            // The segment that joins the tail back to the head closes the
605            // run; it is kept, and the run is closed by it.
606            if (same(seg[0].1, tail) && same(seg[1].1, head))
607                || (same(seg[1].1, tail) && same(seg[0].1, head))
608            {
609                chain.push(chain[0]);
610                used[k] = true;
611                grew = true;
612                break;
613            }
614            let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
615            if covered(seg[0].1) && covered(seg[1].1) {
616                used[k] = true;
617                continue;
618            }
619            if same(seg[0].1, tail) {
620                chain.push(seg[1]);
621            } else if same(seg[1].1, tail) {
622                chain.push(seg[0]);
623            } else if same(seg[0].1, head) {
624                chain.insert(0, seg[1]);
625            } else if same(seg[1].1, head) {
626                chain.insert(0, seg[0]);
627            } else {
628                continue;
629            }
630            used[k] = true;
631            grew = true;
632        }
633        if !grew {
634            break;
635        }
636    }
637    if used.iter().any(|u| !u) {
638        ogeom_bail!(
639            Construction,
640            "the neutral plane crosses the drafted face more than once; \
641             there is no one hinge to turn about"
642        );
643    }
644    let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
645    if closed {
646        chain.pop();
647    }
648    if chain.len() < 2 {
649        ogeom_bail!(
650            Construction,
651            "the neutral plane touches the drafted face at a point; there is \
652             no hinge to turn about"
653        );
654    }
655    let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
656
657    // The mesh's stations are a chord apart; a cubic fitted through them
658    // sits a fraction of that chord off the true hinge. Resampled between
659    // them in the chart (across a periodic seam by the short way), and
660    // corrected onto the surface below, the stations are as dense as the
661    // fit's target wants.
662    let chain: Vec<(f64, f64)> = {
663        let ((ua, ub), (va, vb)) = surface.domain();
664        // Closed counts as periodic here: a fitted tube closes on itself
665        // without repeating, and a chain crossing its join must still take
666        // the short way round.
667        let period = (
668            (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
669            (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
670        );
671        let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
672            let d = b - a;
673            match period {
674                Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
675                _ => d,
676            }
677        };
678        // A closed hinge starts where it crosses the chart's own seam
679        // column, so the drafted support's seam stands where the old one
680        // did and the rebuild finds it there.
681        let chain: Vec<(f64, f64)> = if closed {
682            // The station exactly on the column: where the chain's segments
683            // cross `u = ua`, interpolated there; the nearest chain point
684            // otherwise.
685            let n = chain.len();
686            let mut exact: Option<(usize, (f64, f64))> = None;
687            for i in 0..n {
688                let (a, b) = (chain[i], chain[(i + 1) % n]);
689                let (da, db) = (short(ua, a.0, period.0), short(ua, b.0, period.0));
690                if da == 0.0 {
691                    exact = Some((i, a));
692                    break;
693                }
694                if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
695                    let f = da / (da - db);
696                    let dv = short(a.1, b.1, period.1);
697                    exact = Some((i + 1, (ua, a.1 + dv * f)));
698                    break;
699                }
700            }
701            let (start, inserted) = exact.unwrap_or_else(|| {
702                let mut best = (0usize, f64::INFINITY);
703                for (i, c) in chain.iter().enumerate() {
704                    let d = short(ua, c.0, period.0).abs();
705                    if d < best.1 {
706                        best = (i, d);
707                    }
708                }
709                (best.0, chain[best.0])
710            });
711            let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
712            rotated.push(inserted);
713            for k in 0..n {
714                let c = chain[(start + k) % n];
715                if rotated.len() == 1 && c == inserted {
716                    continue;
717                }
718                rotated.push(c);
719            }
720            rotated
721        } else {
722            chain
723        };
724        // Evenly by arc length, so the fit's parameter is the hinge's own
725        // length: the mesh's segments run from a hair to a chord, and a
726        // cubic through stations that uneven wanders between them.
727        let pairs = if closed { chain.len() } else { chain.len() - 1 };
728        let mut lengths = Vec::with_capacity(pairs);
729        let mut total = 0.0;
730        for i in 0..pairs {
731            let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
732            let step = surface
733                .point_at(a.0, a.1, tol)?
734                .distance(surface.point_at(b.0, b.1, tol)?);
735            lengths.push(step);
736            total += step;
737        }
738        let count = if closed {
739            HINGE_STATIONS
740        } else {
741            HINGE_STATIONS + 1
742        };
743        let mut dense = Vec::with_capacity(count);
744        let (mut pair, mut walked) = (0usize, 0.0_f64);
745        for k in 0..count {
746            #[allow(clippy::cast_precision_loss)]
747            let target = total * k as f64 / HINGE_STATIONS as f64;
748            while pair + 1 < pairs && walked + lengths[pair] < target {
749                walked += lengths[pair];
750                pair += 1;
751            }
752            let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
753            let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
754            let f = if lengths[pair] > 0.0 {
755                ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
756            } else {
757                0.0
758            };
759            dense.push((a.0 + du * f, a.1 + dv * f));
760        }
761        dense
762    };
763
764    // Each station corrected onto the surface's crossing with the plane
765    // (the mesh's chord is not the surface), and read for its tangent and
766    // outward normal.
767    let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
768    let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
769    let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
770    for (k, &(mut u, mut v)) in chain.iter().enumerate() {
771        // The first station of a closed hinge is the seam column's, and
772        // stays on it: corrected along `v` alone.
773        let pinned = closed && k == 0;
774        for _ in 0..8 {
775            let p = surface.point_at(u, v, tol)?;
776            let f = neutral.signed_distance_to(p);
777            if f.abs() <= tol.confusion() * 1e-2 {
778                break;
779            }
780            let (du, dv) = surface.d1_at(u, v, tol)?;
781            let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
782            let g2 = g.0 * g.0 + g.1 * g.1;
783            if g2 <= 0.0 {
784                break;
785            }
786            u -= f * g.0 / g2;
787            v -= f * g.1 / g2;
788        }
789        let p = surface.point_at(u, v, tol)?;
790        let (du, dv) = surface.d1_at(u, v, tol)?;
791        let raw = du.cross(dv);
792        if raw.magnitude() <= tol.confusion() {
793            ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
794        }
795        let outward = raw / raw.magnitude() * sign;
796        // Along the crossing: square to both normals, run the way the chain
797        // runs.
798        let mut t = outward.cross(n);
799        let next = chain[(k + 1) % chain.len()];
800        let prev = chain[(k + chain.len() - 1) % chain.len()];
801        let ahead =
802            surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
803        if t.dot(ahead) < 0.0 {
804            t = -t;
805        }
806        if t.magnitude() <= tol.angular() {
807            ogeom_bail!(
808                Construction,
809                "the neutral plane is tangent to the drafted face; there is no \
810                 hinge to turn about"
811            );
812        }
813        hinges.push(p);
814        tangents.push(t / t.magnitude());
815        outwards.push(outward);
816    }
817
818    // The sense, probed at the middle station as the other paths probe:
819    // the turn whose outward normal leans furthest towards the pull is the
820    // inward one, and the caller's sign picks inward or outward through it.
821    let m = hinges.len() / 2;
822    let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
823    let mut leaning = 1.0;
824    let mut best = f64::NEG_INFINITY;
825    for sense in [1.0_f64, -1.0] {
826        let turn = Transform::rotation(axis_m, angle.abs() * sense);
827        let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
828        if lean > best {
829            best = lean;
830            leaning = sense;
831        }
832    }
833    let theta = angle * leaning;
834
835    // One ruling per station: the pull turned about the hinge's tangent.
836    let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
837    for (hinge, tangent) in hinges.iter().zip(&tangents) {
838        let turn = Transform::rotation(
839            ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
840            theta,
841        );
842        rulings.push(turn.apply_vector(pull.vector()));
843    }
844    // How far the face reaches along the pull either side of its hinge,
845    // grown with the turn so the wall still reaches the neighbours it
846    // re-meets.
847    // Measured from every hinge station, not the middle one: an oblique
848    // hinge rises and falls along the pull, and a ruling from its low end
849    // must still reach the face's top.
850    let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
851    let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
852    for h in &hinges {
853        let s = h.to_vector().dot(pull.vector());
854        h_lo = h_lo.min(s);
855        h_hi = h_hi.max(s);
856    }
857    for p in &mesh.positions {
858        let s = p.to_vector().dot(pull.vector());
859        s_lo = s_lo.min(s - h_hi);
860        s_hi = s_hi.max(s - h_lo);
861    }
862    let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
863    let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
864    if !closed {
865        // An open hinge is continued straight past both ends, for the
866        // same reason.
867        let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
868            let (i0, i1) = if front {
869                (0, 1)
870            } else {
871                (hinges.len() - 1, hinges.len() - 2)
872            };
873            let d1 = hinges[i0] - hinges[i1];
874            let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
875            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
876            let steps = (steps as usize).min(16);
877            for k in 1..=steps {
878                #[allow(clippy::cast_precision_loss)]
879                let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
880                if front {
881                    hinges.insert(0, station.0);
882                    rulings.insert(0, station.1);
883                } else {
884                    hinges.push(station.0);
885                    rulings.push(station.1);
886                }
887            }
888        };
889        extend(&mut hinges, &mut rulings, true);
890        extend(&mut hinges, &mut rulings, false);
891    } else {
892        hinges.push(hinges[0]);
893        rulings.push(rulings[0]);
894    }
895    for edge in [s_lo, s_hi] {
896        for i in 0..hinges.len() - 1 {
897            let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
898            if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
899                ogeom_bail!(
900                    Construction,
901                    "the draft folds the wall onto itself inside the drafted \
902                     window; refused; see docs/PARITY.md, offset.draft"
903                );
904            }
905        }
906    }
907    // The ruled surface itself, exactly: the hinge fitted as a cubic, the
908    // rulings' tips fitted as another at the *same* parameters, the two
909    // brought onto one knot vector, and the surface linear between them:
910    // degree one along the ruling, so a straight line is a straight line
911    // and the fit's only error is the two curves' own. A grid fit through
912    // rows at several heights parameterizes each row by its own chord and
913    // averages, and rows that converge along their rulings disagree by
914    // enough for a cubic across them to wander.
915    // Chord-length parameters, not centripetal: the stations are as evenly
916    // spaced as the mesh's segments let them be, and centripetal
917    // parameters kink wherever the spacing changes, which a cubic then
918    // cannot follow. By chord the parameter is the arc length whatever
919    // the spacing.
920    let params: Vec<f64> = {
921        let mut out = Vec::with_capacity(hinges.len());
922        let mut total = 0.0;
923        out.push(0.0);
924        for pair in hinges.windows(2) {
925            total += pair[0].distance(pair[1]);
926            out.push(total);
927        }
928        if total > 0.0 {
929            for t in &mut out {
930                *t /= total;
931            }
932        }
933        if let Some(last) = out.last_mut() {
934            *last = 1.0;
935        }
936        out
937    };
938    let reach = s_lo.abs().max(s_hi.abs()).max(1.0);
939    let fit_target = (tol.confusion() * 1e3).max(1e-4);
940    let hinge_fit = ogeom_geom::fit::fit_points_at(&params, &hinges, 3, fit_target, tol)?;
941    let tips: Vec<Point> = hinges.iter().zip(&rulings).map(|(h, r)| *h + *r).collect();
942    let tip_fit = ogeom_geom::fit::fit_points_at(&params, &tips, 3, fit_target / reach, tol)?;
943    if !hinge_fit.met || !tip_fit.met {
944        ogeom_bail!(
945            NotDone,
946            "the drafted wall's hinge fit reached {} and its rulings' {} against \
947             a target of {fit_target}",
948            hinge_fit.error,
949            tip_fit.error * reach
950        );
951    }
952    let (mut hinge_curve, mut tip_curve) = (hinge_fit.curve, tip_fit.curve);
953    for (value, count) in tip_curve.knots().distinct() {
954        let have = hinge_curve.knots().multiplicity_of(value);
955        if count > have {
956            hinge_curve = hinge_curve.with_knot_inserted(value, count - have, tol)?;
957        }
958    }
959    for (value, count) in hinge_curve.knots().distinct() {
960        let have = tip_curve.knots().multiplicity_of(value);
961        if count > have {
962            tip_curve = tip_curve.with_knot_inserted(value, count - have, tol)?;
963        }
964    }
965    let (hc, tc) = (hinge_curve.control_points(), tip_curve.control_points());
966    if hc.len() != tc.len() {
967        ogeom_bail!(
968            Construction,
969            "the hinge and its rulings did not share a knot vector"
970        );
971    }
972    // The face keeps its orientation flag, so the new surface's own normal
973    // must turn the way the old one's did; whether it does depends on which
974    // way the hinge chained. Measured at the hinge's middle, where the two
975    // surfaces meet, and the rulings run from the far end back if not.
976    let (hinge_mid, tip_mid) = (
977        hinge_curve.point_at(0.5, tol)?,
978        tip_curve.point_at(0.5, tol)?,
979    );
980    let across = hinge_curve.d1_at(0.5, tol)?;
981    let old_normal = {
982        let foot = ogeom_algo::project_on_surface(surface, hinge_mid, 16, tol)?;
983        let (u, v) = foot.parameters;
984        surface.normal_at(u, v, tol)?.vector()
985    };
986    // du x dv of the new surface at the hinge: along the hinge, crossed
987    // with up the ruling.
988    let agrees = across.cross(tip_mid - hinge_mid).dot(old_normal) >= 0.0;
989    let (first, second, v_range) = if agrees {
990        (s_lo, s_hi, (s_lo, s_hi))
991    } else {
992        (s_hi, s_lo, (-s_hi, -s_lo))
993    };
994    let mut net: Vec<Point> = Vec::with_capacity(hc.len() * 2);
995    for (h, t) in hc.iter().zip(tc) {
996        let (h, d) = (h.point(), t.point() - h.point());
997        net.push(h + d * first);
998        net.push(h + d * second);
999    }
1000    let grid = ogeom_math::ControlGrid::new(net, hc.len(), 2)?;
1001    // The chart: `u` over the old surface's own `u` domain for a closed
1002    // hinge, so the seam column carries over; `v` the height along the
1003    // ruling in the model's own units.
1004    let u_knots = if closed {
1005        let ((ua, ub), _) = surface.domain();
1006        hinge_curve.knots().reparameterized(ua, ub)?
1007    } else {
1008        hinge_curve.knots().clone()
1009    };
1010    let v_knots =
1011        ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(v_range.0, v_range.1)?;
1012    Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
1013}
1014
1015/// Which sign turns a surface's raw normal (du x dv) outward, read from
1016/// the solid itself: probed a step off the face midpoint on both sides, at
1017/// growing steps until one side is material and the other is not.
1018///
1019/// # Errors
1020///
1021/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if no
1022/// probe separates the sides: a wall thinner than the probe can resolve.
1023fn outward_sign(
1024    model: &Model,
1025    solid: &Shape,
1026    face: &Shape,
1027    surface: &SurfaceGeometry,
1028    tol: Tolerances,
1029) -> OgeomResult<f64> {
1030    use ogeom_algo::Containment;
1031    // A point genuinely on the face (the surface's domain midpoint may lie
1032    // outside the trim), from the face's own triangulation, at its largest
1033    // triangle's centre.
1034    let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1035    let mut at = None;
1036    let mut largest = 0.0_f64;
1037    for t in &mesh.triangles {
1038        let [a, b, c] = [
1039            mesh.positions[t[0] as usize],
1040            mesh.positions[t[1] as usize],
1041            mesh.positions[t[2] as usize],
1042        ];
1043        let area = (b - a).cross(c - a).magnitude();
1044        if area > largest {
1045            largest = area;
1046            let params = [
1047                mesh.parameters[t[0] as usize],
1048                mesh.parameters[t[1] as usize],
1049                mesh.parameters[t[2] as usize],
1050            ];
1051            at = Some((
1052                (params[0].0 + params[1].0 + params[2].0) / 3.0,
1053                (params[0].1 + params[1].1 + params[2].1) / 3.0,
1054            ));
1055        }
1056    }
1057    let Some((um, vm)) = at else {
1058        ogeom_bail!(Construction, "the drafted face has no interior to probe");
1059    };
1060    let p = surface.point_at(um, vm, tol)?;
1061    let (du, dv) = surface.d1_at(um, vm, tol)?;
1062    let n = du.cross(dv);
1063    let m = n.magnitude();
1064    if m <= tol.confusion() {
1065        ogeom_bail!(Construction, "the face has no normal at its midpoint");
1066    }
1067    let n = n / m;
1068    let scale = largest.sqrt().max(tol.confusion() * 1e3);
1069    for eps_scale in [1e-3, 1e-2, 5e-2] {
1070        let eps = scale * eps_scale;
1071        let deflection = ogeom_mesh::Deflection {
1072            chord: (eps * 0.1).max(1e-4),
1073            ..ogeom_mesh::Deflection::default()
1074        };
1075        let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1076        let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1077        match (ahead, behind) {
1078            (Containment::Out, Containment::In) => return Ok(1.0),
1079            (Containment::In, Containment::Out) => return Ok(-1.0),
1080            _ => {}
1081        }
1082    }
1083    ogeom_bail!(
1084        Construction,
1085        "cannot read which side of the drafted face holds material; the          wall is thinner than the probe can resolve"
1086    )
1087}
1088
1089/// A point on the line where two planes meet, nearest their origins.
1090fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1091    let rows = [a.normal().vector(), b.normal().vector(), along];
1092    let rhs = [
1093        rows[0].dot(a.origin().to_vector()),
1094        rows[1].dot(b.origin().to_vector()),
1095        along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1096    ];
1097    let det = rows[0].dot(rows[1].cross(rows[2]));
1098    if det.abs() <= tol.confusion() {
1099        ogeom_bail!(Construction, "the two planes do not meet in a line");
1100    }
1101    Ok(Point::ORIGIN
1102        + (rows[1].cross(rows[2]) * rhs[0]
1103            + rows[2].cross(rows[0]) * rhs[1]
1104            + rows[0].cross(rows[1]) * rhs[2])
1105            / det)
1106}