Skip to main content

brep_kernel/edit/direct_edit/
face_draft.rs

1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3use crate::offset_retrim::{rebuild_loop_pcurves, retrim_planar_face};
4use crate::{RevolutionFrame, Vec4};
5use super::face_move::{carrier_ruled, retrim_ruled_face};
6use super::face_rotate::rotation_about;
7
8// ---------------------------------------------------------------------------
9// DRAFT FACES: tilt chosen faces of a solid about a neutral plane, the way a
10// mould designer drafts the walls of a part so it can be pulled out.
11//
12// The contract (what a draft IS, so every branch below can be checked against
13// it): given a PULL direction `d`, a NEUTRAL plane (through `neutral_point`,
14// normal `d`) and an angle `α`, every selected face is replaced by its drafted
15// carrier, and every other face keeps its carrier and is re-trimmed to meet the
16// drafted ones.
17//
18// * A PLANE parallel to `d` turns about its NEUTRAL LINE (where it crosses the
19//   neutral plane) until it leans `α` from `d`. Positive `α` leans it INTO the
20//   material as it goes along `d`: its outward normal `m` becomes
21//   `m·cos α + d·sin α`. So positive drafts shrink a boss and widen a pocket
22//   or a hole as they rise, which is what a part pulled along `d` needs.
23// * A CYLINDER whose axis is parallel to `d` becomes the CONE about the same
24//   axis through the cylinder's circle on the neutral plane, its every ruling
25//   leaning `α` from `d` the same way: radius `r₀ − σ·h·tan α` at height `h`
26//   above the neutral plane, `σ = +1` where the material is inside the
27//   cylinder (a boss), `−1` where it is outside (a hole).
28//
29// Both drafted carriers contain the face's NEUTRAL CURVE, and both are ruled
30// along `d` tilted by `α`, so two drafted faces meet in a straight line through
31// the point where their old common edge crossed the neutral plane (a plane
32// pair: the line of their planes; a tangent plane/cone pair, or a cone's seam:
33// the shared ruling). That one fact places every corner the draft moves.
34//
35// The topology is NOT changed: the same faces, edges and vertices come out,
36// with new geometry. A draft that would change it — one that consumes a face,
37// as a steep draft consumes the top of a narrow box — is taken by the convex
38// route below where the body is a convex polyhedron (the result is then the
39// intersection of the faces' half-spaces, the drafted ones replaced), and
40// refused by name everywhere else. A face that crosses the neutral plane and
41// must be drafted AWAY from it on both sides is a PARTING-LINE draft:
42// [`draft_faces_parted`] splits the body there and drafts each side.
43//
44// What is refused, by name, before any geometry is built: a selected face that
45// is neither a plane parallel to the pull nor a cylinder about it, a draft of
46// 90° or more, a vertex whose carriers this file has no closed-form solve for,
47// and a curved edge that is not a section of a cylinder or cone by a plane.
48// Nothing here returns a body it has not validated.
49// ---------------------------------------------------------------------------
50
51const OP: &str = "draft_faces";
52
53/// The refusal slug a draft that turns an edge end over end, or collapses
54/// it, is minted with: the topology the draft would need has fewer faces or
55/// edges than the input. The convex route takes these; everything else refuses.
56const CONSUMES_FACE: &str = "draft_consumes_face";
57
58/// A face's carrier AFTER the draft, in closed form.
59#[derive(Clone)]
60enum Carrier {
61    /// Unit `normal`; the sign is the surface's own, not the outward one.
62    Plane { origin: Vec3, normal: Vec3 },
63    /// A cylinder or cone: radius `rho0 + (rho1 − rho0)·h/height` at axial
64    /// height `h = (p − frame.origin)·frame.axis` — the parameters
65    /// `plane_ruled_section_arc` reads.
66    Ruled { frame: RevolutionFrame, rho0: f64, rho1: f64, height: f64 },
67}
68
69impl Carrier {
70    fn residual(&self, point: Vec3) -> f64 {
71        match self {
72            Carrier::Plane { origin, normal } => point.sub(*origin).dot(*normal),
73            Carrier::Ruled { frame, rho0, rho1, height } => {
74                let w = point.sub(frame.origin);
75                let h = w.dot(frame.axis);
76                let radial = w.sub(frame.axis.scale(h)).length();
77                radial - (rho0 + (rho1 - rho0) * h / height)
78            }
79        }
80    }
81}
82
83/// How a selected face is drafted.
84#[derive(Clone)]
85enum Draft {
86    /// The face's old plane frame, its OUTWARD normal `outward` (horizontal),
87    /// and the rotation that carries the old frame to the drafted one.
88    Plane { old: Plane, outward: Vec3, rotation: AffineTransform },
89    /// A cylinder about an axis through `axis_point` (on the neutral plane)
90    /// parallel to the pull; `sign` is σ (+1 a boss, −1 a hole).
91    Cylinder { axis_point: Vec3, radius: f64, sign: f64 },
92}
93
94/// The surface normal of `face` turned to point OUT of the material.
95fn outward_sign(face: &FaceRecord) -> f64 {
96    if face.same_sense {
97        1.0
98    } else {
99        -1.0
100    }
101}
102
103/// A unit vector perpendicular to `axis` (the frame's `x_axis`).
104fn frame_about(origin: Vec3, axis: Vec3) -> Result<RevolutionFrame, KernelRefusal> {
105    let x_axis = axis.perpendicular().or_refuse(KernelStage::Classify, "perpendicular")?;
106    let y_axis = axis.cross(x_axis).normalized().or_refuse(KernelStage::Classify, "normalized")?;
107    Ok(RevolutionFrame { origin, axis, x_axis, y_axis })
108}
109
110/// A cylinder whose axis is parallel to `pull`, recognised from the surface
111/// itself: every sampled normal square to `pull`, and every sampled point the
112/// same distance from one line along `pull`. Returns the axis point (in the
113/// plane through the world origin square to `pull`) and the radius. The tag
114/// alone is not enough: an extruded arc carries no revolution tag.
115fn vertical_cylinder(surface: &NurbsSurface, pull: Vec3, tolerance: f64) -> Option<(Vec3, f64)> {
116    let [u0, u1] = surface.domain_u().ok()?;
117    let [v0, v1] = surface.domain_v().ok()?;
118    let ex = pull.perpendicular().ok()?;
119    let ey = pull.cross(ex).normalized().ok()?;
120    let mut samples: Vec<[f64; 2]> = Vec::with_capacity(25);
121    for i in 0..5 {
122        for j in 0..5 {
123            let u = u0 + (u1 - u0) * (0.05 + 0.9 * i as f64 / 4.0);
124            let v = v0 + (v1 - v0) * (0.05 + 0.9 * j as f64 / 4.0);
125            let normal = surface.normal(u, v).ok()?;
126            if normal.dot(pull).abs() > 1e-7 {
127                return None;
128            }
129            let point = surface.evaluate(u, v).ok()?;
130            samples.push([point.dot(ex), point.dot(ey)]);
131        }
132    }
133    let distance = |a: [f64; 2], b: [f64; 2]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt();
134    let a = samples[0];
135    let b = *samples.iter().max_by(|p, q| distance(a, **p).total_cmp(&distance(a, **q)))?;
136    let area = |p: [f64; 2]| ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])).abs();
137    let c = *samples.iter().max_by(|p, q| area(**p).total_cmp(&area(**q)))?;
138    if area(c) <= 1e-9 * distance(a, b).powi(2).max(1e-30) {
139        return None; // the samples are collinear: a plane, not a cylinder
140    }
141    let (bx, by) = (b[0] - a[0], b[1] - a[1]);
142    let (cx, cy) = (c[0] - a[0], c[1] - a[1]);
143    let det = 2.0 * (bx * cy - by * cx);
144    let ux = (cy * (bx * bx + by * by) - by * (cx * cx + cy * cy)) / det;
145    let uy = (bx * (cx * cx + cy * cy) - cx * (bx * bx + by * by)) / det;
146    let center = [a[0] + ux, a[1] + uy];
147    let radius = (ux * ux + uy * uy).sqrt();
148    if samples.iter().any(|p| (distance(*p, center) - radius).abs() > tolerance) {
149        return None;
150    }
151    Some((ex.scale(center[0]).add(ey.scale(center[1])), radius))
152}
153
154/// The closed-form carrier of a face as it stands: its plane, or its cylinder
155/// or cone. `None` for every other carrier.
156fn carrier_of(
157    solid: &BrepSolid,
158    face_lookup: &HashMap<u64, (usize, usize)>,
159    face_id: u64,
160    pull: Vec3,
161    plane_tolerance: f64,
162) -> Option<Carrier> {
163    let &(shell, index) = face_lookup.get(&face_id)?;
164    let surface = &solid.shells[shell].faces[index].surface;
165    if let Ok(plane) = plane_of_surface(surface, plane_tolerance, OP) {
166        return Some(Carrier::Plane { origin: plane.origin, normal: plane.normal });
167    }
168    if let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, face_lookup, face_id) {
169        return Some(Carrier::Ruled { frame, rho0, rho1, height });
170    }
171    let (axis_point, radius) = vertical_cylinder(surface, pull, plane_tolerance)?;
172    let frame = frame_about(axis_point, pull).ok()?;
173    Some(Carrier::Ruled { frame, rho0: radius, rho1: radius, height: 1.0 })
174}
175
176/// Unwrapped signed azimuth swept by `curve` over `[t0, t1]` about `frame`.
177fn swept_azimuth(frame: &RevolutionFrame, curve: &NurbsCurve, t0: f64, t1: f64) -> Result<f64, KernelRefusal> {
178    let azimuth = |point: Vec3| {
179        let w = point.sub(frame.origin);
180        w.dot(frame.y_axis).atan2(w.dot(frame.x_axis))
181    };
182    let steps = 64;
183    let mut previous = azimuth(curve.evaluate(t0).or_refuse(KernelStage::Refine, "evaluate")?);
184    let mut total = 0.0;
185    for step in 1..=steps {
186        let t = t0 + (t1 - t0) * step as f64 / steps as f64;
187        let next = azimuth(curve.evaluate(t).or_refuse(KernelStage::Refine, "evaluate")?);
188        let mut delta = next - previous;
189        while delta > std::f64::consts::PI {
190            delta -= std::f64::consts::TAU;
191        }
192        while delta < -std::f64::consts::PI {
193            delta += std::f64::consts::TAU;
194        }
195        total += delta;
196        previous = next;
197    }
198    Ok(total)
199}
200
201/// Where the line `point + t·direction` meets `carrier`, nearest `near`.
202fn line_meets(carrier: &Carrier, point: Vec3, direction: Vec3, near: Vec3) -> Option<Vec3> {
203    match carrier {
204        Carrier::Plane { origin, normal } => {
205            let denominator = normal.dot(direction);
206            if denominator.abs() <= 1e-12 {
207                return None;
208            }
209            let t = normal.dot(origin.sub(point)) / denominator;
210            Some(point.add(direction.scale(t)))
211        }
212        Carrier::Ruled { frame, rho0, rho1, height } => {
213            let k = (rho1 - rho0) / height;
214            let w0 = point.sub(frame.origin);
215            let h0 = w0.dot(frame.axis);
216            let q0 = w0.sub(frame.axis.scale(h0));
217            let h1 = direction.dot(frame.axis);
218            let q1 = direction.sub(frame.axis.scale(h1));
219            let r0 = rho0 + k * h0;
220            let a = q1.dot(q1) - k * k * h1 * h1;
221            let b = 2.0 * (q0.dot(q1) - r0 * k * h1);
222            let c = q0.dot(q0) - r0 * r0;
223            let mut roots = Vec::new();
224            if a.abs() <= 1e-14 * (b.abs() + c.abs()).max(1e-300) {
225                if b.abs() > 0.0 {
226                    roots.push(-c / b);
227                }
228            } else {
229                let disc = b * b - 4.0 * a * c;
230                if disc < 0.0 {
231                    return None;
232                }
233                let root = disc.sqrt();
234                roots.push((-b + root) / (2.0 * a));
235                roots.push((-b - root) / (2.0 * a));
236            }
237            roots
238                .into_iter()
239                .filter(|t| r0 + k * h1 * t >= 0.0)
240                .map(|t| point.add(direction.scale(t)))
241                .min_by(|p, q| p.sub(near).length().total_cmp(&q.sub(near).length()))
242        }
243    }
244}
245
246/// The common point of three or more planes (least squares, rank 3 required).
247fn planes_meet(planes: &[(Vec3, Vec3)]) -> Option<Vec3> {
248    let mut m = [[0.0f64; 3]; 3];
249    let mut rhs = [0.0f64; 3];
250    for (origin, normal) in planes {
251        let n = [normal.x, normal.y, normal.z];
252        let b = normal.dot(*origin);
253        for r in 0..3 {
254            for c in 0..3 {
255                m[r][c] += n[r] * n[c];
256            }
257            rhs[r] += n[r] * b;
258        }
259    }
260    let det = |m: &[[f64; 3]; 3]| {
261        m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
262            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
263            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
264    };
265    let d = det(&m);
266    let trace = m[0][0] + m[1][1] + m[2][2];
267    if d.abs() <= 1e-10 * trace.powi(3) {
268        return None;
269    }
270    let mut solution = [0.0f64; 3];
271    for column in 0..3 {
272        let mut replaced = m;
273        for row in 0..3 {
274            replaced[row][column] = rhs[row];
275        }
276        solution[column] = det(&replaced) / d;
277    }
278    Some(Vec3::new(solution[0], solution[1], solution[2]))
279}
280
281/// Draft `face_ids` of `solid` by `angle_radians` for the pull direction
282/// `pull_direction`, about the neutral plane through `neutral_point` square to
283/// the pull. See the contract at the top of this file: a positive angle leans
284/// each face into the material as it goes along the pull (a boss narrows, a
285/// pocket or a hole widens); a negative one leans it out.
286///
287/// Each selected face must be a plane parallel to the pull or a cylinder whose
288/// axis is parallel to it. The other faces keep their carriers and are
289/// re-trimmed to the drafted ones; the body's topology is unchanged, except on
290/// a convex polyhedron, where a draft that consumes a face is built as the
291/// intersection of the faces' half-spaces. Anything else is refused by name.
292pub fn draft_faces(
293    solid: &BrepSolid,
294    face_ids: &[u64],
295    pull_direction: Vec3,
296    neutral_point: Vec3,
297    angle_radians: f64,
298) -> Result<BrepSolid, KernelRefusal> {
299    match draft_faces_in_place(solid, face_ids, pull_direction, neutral_point, angle_radians) {
300        Err(refusal)
301            if matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what } if what == CONSUMES_FACE) =>
302        {
303            draft_convex_polyhedron(solid, face_ids, pull_direction, neutral_point, angle_radians)
304                .map_err(|convex| {
305                    KernelRefusal::unsupported(KernelStage::Classify, CONSUMES_FACE, format!(
306                        "{OP}: the draft changes the body's topology ({refusal}), and the convex \
307                         route that builds such a draft cannot take this body ({convex}) — refusing"
308                    ))
309                })
310        }
311        other => other,
312    }
313}
314
315fn check_inputs(
316    face_ids: &[u64],
317    pull_direction: Vec3,
318    neutral_point: Vec3,
319    angle_radians: f64,
320) -> Result<Vec3, KernelRefusal> {
321    let finite = |v: Vec3| v.x.is_finite() && v.y.is_finite() && v.z.is_finite();
322    if !finite(pull_direction) {
323        return Err(KernelRefusal::input(KernelStage::Collect, "pull_direction", format!("{OP}: the pull direction must be finite")));
324    }
325    if !finite(neutral_point) {
326        return Err(KernelRefusal::input(KernelStage::Collect, "neutral_point", format!("{OP}: the neutral point must be finite")));
327    }
328    if !angle_radians.is_finite() || angle_radians.abs() >= 89.0f64.to_radians() {
329        return Err(KernelRefusal::input(KernelStage::Collect, "angle", format!(
330            "{OP}: the draft angle must be finite and less than 89° either way (got {angle_radians})"
331        )));
332    }
333    if face_ids.is_empty() {
334        return Err(KernelRefusal::input(KernelStage::Collect, "selection", format!("{OP}: no faces selected")));
335    }
336    pull_direction.normalized().map_err(|_| {
337        KernelRefusal::input(KernelStage::Collect, "pull_direction", format!("{OP}: the pull direction has zero length"))
338    })
339}
340
341fn draft_faces_in_place(
342    solid: &BrepSolid,
343    face_ids: &[u64],
344    pull_direction: Vec3,
345    neutral_point: Vec3,
346    angle_radians: f64,
347) -> Result<BrepSolid, KernelRefusal> {
348    let pull = check_inputs(face_ids, pull_direction, neutral_point, angle_radians)?;
349    let scale = solid_model_scale(solid);
350    let tolerance = (scale * 1e-7).max(1e-9);
351    let plane_tolerance = (scale * 1e-6).max(1e-7);
352    let accept = 10.0 * plane_tolerance;
353    let (sin, cos) = angle_radians.sin_cos();
354    let tan = angle_radians.tan();
355    let height_of = |point: Vec3| point.sub(neutral_point).dot(pull);
356
357    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
358    for (shell_index, shell) in solid.shells.iter().enumerate() {
359        for (face_index, face) in shell.faces.iter().enumerate() {
360            face_lookup.entry(face.id).or_insert((shell_index, face_index));
361        }
362    }
363    let selected: HashSet<u64> = face_ids.iter().copied().collect();
364    for &face_id in &selected {
365        if !face_lookup.contains_key(&face_id) {
366            return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("{OP}: no face with id {face_id}")));
367        }
368    }
369    if angle_radians == 0.0 {
370        return Ok(solid.clone());
371    }
372
373    // --- Classify every selected face, and build its drafted carrier --------
374    let mut drafts: HashMap<u64, Draft> = HashMap::default();
375    let mut carriers: HashMap<u64, Carrier> = HashMap::default();
376    for &face_id in &selected {
377        let (shell, index) = face_lookup[&face_id];
378        let face = &solid.shells[shell].faces[index];
379        let sign = outward_sign(face);
380        if let Ok(old) = plane_of_surface(&face.surface, plane_tolerance, OP) {
381            let outward = old.normal.scale(sign);
382            if outward.dot(pull).abs() > 1e-9 {
383                return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_plane_not_parallel", format!(
384                    "{OP}: face {face_id} is a plane that is not parallel to the pull direction \
385                     (its normal makes {:.6}° with the pull); a draft turns a face that runs along \
386                     the pull — refusing",
387                    outward.dot(pull).clamp(-1.0, 1.0).acos().to_degrees()
388                )));
389            }
390            // Turning about k = m × d by +α carries m toward d: m' = m·cos α + d·sin α.
391            let hinge = outward.cross(pull).normalized().or_refuse(KernelStage::Classify, "hinge")?;
392            let on_hinge = old.origin.add(pull.scale(-height_of(old.origin)));
393            let rotation = rotation_about(on_hinge, hinge, angle_radians).or_refuse(KernelStage::Classify, "rotation")?;
394            let turned = super::face_rotate::rotate_direction(&rotation, old.normal);
395            carriers.insert(face_id, Carrier::Plane { origin: on_hinge, normal: turned });
396            drafts.insert(face_id, Draft::Plane { old, outward, rotation });
397            continue;
398        }
399        let Some((axis_point, radius)) = vertical_cylinder(&face.surface, pull, plane_tolerance) else {
400            return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_carrier_kind", format!(
401                "{OP}: face {face_id} is neither a plane parallel to the pull direction nor a \
402                 cylinder whose axis is the pull direction; a draft turns those into a tilted plane \
403                 and a cone, and has no drafted carrier for this one — refusing"
404            )));
405        };
406        let axis_point = axis_point.add(pull.scale(height_of(axis_point) * -1.0));
407        // σ: +1 where the outward normal points away from the axis (a boss).
408        let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Classify, "domain_u")?;
409        let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
410        let (um, vm) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
411        let at = face.surface.evaluate(um, vm).or_refuse(KernelStage::Classify, "evaluate")?;
412        let normal = face.surface.normal(um, vm).or_refuse(KernelStage::Classify, "normal")?.scale(sign);
413        let radial = at.sub(axis_point);
414        let radial = radial.sub(pull.scale(radial.dot(pull)));
415        let draft_sign = if normal.dot(radial) > 0.0 { 1.0 } else { -1.0 };
416        let frame = frame_about(axis_point, pull)?;
417        // Any height names the same cone; this one keeps both radii positive.
418        let height = 0.5 * radius / tan.abs();
419        carriers.insert(face_id, Carrier::Ruled {
420            frame,
421            rho0: radius,
422            rho1: radius - draft_sign * tan * height,
423            height,
424        });
425        drafts.insert(face_id, Draft::Cylinder { axis_point, radius, sign: draft_sign });
426    }
427    // The generator of a drafted face through `point`: the pull, leaned α
428    // into the material.
429    let generator = |face_id: u64, point: Vec3| -> Option<Vec3> {
430        let outward = match drafts.get(&face_id)? {
431            Draft::Plane { outward, .. } => *outward,
432            Draft::Cylinder { axis_point, sign, .. } => {
433                let radial = point.sub(*axis_point);
434                radial.sub(pull.scale(radial.dot(pull))).normalized().ok()?.scale(*sign)
435            }
436        };
437        pull.scale(cos).sub(outward.scale(sin)).normalized().ok()
438    };
439
440    // --- Topology maps --------------------------------------------------------
441    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
442    for shell in &solid.shells {
443        for face in &shell.faces {
444            for loop_record in &face.loops {
445                for coedge in &loop_record.coedges {
446                    faces_of_edge.entry(coedge.edge_id).or_default().push(face.id);
447                }
448            }
449        }
450    }
451    let mut edges_at: HashMap<u64, Vec<usize>> = HashMap::default();
452    for (index, edge) in solid.edges.iter().enumerate() {
453        let uses = faces_of_edge.get(&edge.id).map(Vec::len).unwrap_or(0);
454        let expected = if edge.degenerate { 1 } else { 2 };
455        if uses != expected {
456            return Err(KernelRefusal::input(KernelStage::Collect, "non_manifold", format!(
457                "{OP}: edge {} is used {uses} times (non-manifold input)", edge.id
458            )));
459        }
460        edges_at.entry(edge.start_vertex_id).or_default().push(index);
461        if edge.end_vertex_id != edge.start_vertex_id {
462            edges_at.entry(edge.end_vertex_id).or_default().push(index);
463        }
464    }
465    let touches_draft = |edge: &EdgeRecord| faces_of_edge[&edge.id].iter().any(|f| selected.contains(f));
466
467    // The carriers of every face that meets a drafted one: fixed faces keep
468    // their own. One whose carrier has no closed form refuses here, by name.
469    let mut neighbours: HashSet<u64> = HashSet::default();
470    for edge in &solid.edges {
471        if touches_draft(edge) {
472            neighbours.extend(faces_of_edge[&edge.id].iter().copied());
473        }
474    }
475    for vertex_edges in edges_at.values() {
476        if vertex_edges.iter().any(|&e| touches_draft(&solid.edges[e])) {
477            for &e in vertex_edges {
478                neighbours.extend(faces_of_edge[&solid.edges[e].id].iter().copied());
479            }
480        }
481    }
482    let mut sorted_neighbours: Vec<u64> = neighbours.iter().copied().collect();
483    sorted_neighbours.sort_unstable();
484    for face_id in sorted_neighbours {
485        if carriers.contains_key(&face_id) {
486            continue;
487        }
488        let Some(carrier) = carrier_of(solid, &face_lookup, face_id, pull, plane_tolerance) else {
489            return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_neighbour_kind", format!(
490                "{OP}: face {face_id}, which meets a drafted face, is not a plane, a cylinder or \
491                 a cone; the draft re-trims its neighbours by closed-form sections only — refusing"
492            )));
493        };
494        carriers.insert(face_id, carrier);
495    }
496
497    // --- Re-solve every vertex the draft touches ----------------------------
498    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
499    for vertex in &solid.vertices {
500        let Some(vertex_edges) = edges_at.get(&vertex.id) else { continue };
501        let mut at: Vec<u64> = vertex_edges
502            .iter()
503            .flat_map(|&e| faces_of_edge[&solid.edges[e].id].iter().copied())
504            .collect();
505        at.sort_unstable();
506        at.dedup();
507        if !at.iter().any(|f| selected.contains(f)) {
508            continue;
509        }
510        let worst = |point: Vec3| at.iter().map(|f| carriers[f].residual(point).abs()).fold(0.0, f64::max);
511        if worst(vertex.point) <= tolerance {
512            new_vertex.insert(vertex.id, vertex.point); // on the neutral plane: it stays
513            continue;
514        }
515        // Lines the corner must lie on: an edge both of whose faces keep their
516        // carriers keeps its own line, and an edge both of whose faces are
517        // drafted becomes the line through its neutral point.
518        let mut lines: Vec<(Vec3, Vec3)> = Vec::new();
519        for &e in vertex_edges {
520            let edge = &solid.edges[e];
521            if edge.degenerate {
522                continue;
523            }
524            let Some((a, b)) = edge.curve.straight_segment(tolerance) else { continue };
525            let uses = &faces_of_edge[&edge.id];
526            let drafted = uses.iter().filter(|f| selected.contains(*f)).count();
527            let direction = b.sub(a);
528            if drafted == 0 {
529                lines.push((a, direction));
530            } else if drafted == uses.len() {
531                let along = direction.dot(pull);
532                if along.abs() <= 1e-9 * direction.length() {
533                    continue; // a horizontal edge between drafted faces fixes nothing here
534                }
535                let crossing = a.add(direction.scale(-height_of(a) / along));
536                let mut line_direction = None;
537                if let (Some(Carrier::Plane { normal: na, .. }), Some(Carrier::Plane { normal: nb, .. })) =
538                    (carriers.get(&uses[0]), carriers.get(&uses[1]))
539                {
540                    let cross = na.cross(*nb);
541                    if cross.length() > 1e-9 {
542                        line_direction = cross.normalized().ok();
543                    }
544                }
545                let line_direction = match line_direction.or_else(|| generator(uses[0], crossing)) {
546                    Some(direction) => direction,
547                    None => continue,
548                };
549                let probe = crossing.add(line_direction.scale(scale));
550                if uses.iter().all(|f| {
551                    carriers[f].residual(crossing).abs() <= accept && carriers[f].residual(probe).abs() <= accept
552                }) {
553                    lines.push((crossing, line_direction));
554                }
555            }
556        }
557        let mut best: Option<(f64, Vec3)> = None;
558        for &(point, direction) in &lines {
559            for face_id in &at {
560                let carrier = &carriers[face_id];
561                let probe = point.add(direction.scale(scale));
562                if carrier.residual(point).abs() <= tolerance && carrier.residual(probe).abs() <= tolerance {
563                    continue; // the line lies on this carrier: it fixes nothing
564                }
565                if let Some(candidate) = line_meets(carrier, point, direction, vertex.point) {
566                    let error = worst(candidate);
567                    if best.map_or(true, |(e, _)| error < e) {
568                        best = Some((error, candidate));
569                    }
570                }
571            }
572        }
573        if best.map_or(true, |(e, _)| e > accept) {
574            let planes: Option<Vec<(Vec3, Vec3)>> = at
575                .iter()
576                .map(|f| match &carriers[f] {
577                    Carrier::Plane { origin, normal } => Some((*origin, *normal)),
578                    _ => None,
579                })
580                .collect();
581            if let Some(candidate) = planes.as_deref().filter(|p| p.len() >= 3).and_then(planes_meet) {
582                let error = worst(candidate);
583                if best.map_or(true, |(e, _)| error < e) {
584                    best = Some((error, candidate));
585                }
586            }
587        }
588        match best {
589            Some((error, point)) if error <= accept => {
590                new_vertex.insert(vertex.id, point);
591            }
592            _ => {
593                return Err(KernelRefusal::unsupported(KernelStage::Refine, "draft_corner", format!(
594                    "{OP}: the corner at vertex {} where faces {at:?} meet has no closed-form \
595                     solve on the drafted carriers (a corner must lie on an edge line both of \
596                     whose faces are drafted or both fixed, or on three planes) — refusing",
597                    vertex.id
598                )));
599            }
600        }
601    }
602
603    // --- Rebuild every edge the draft touches -------------------------------
604    let point_of = |id: u64| -> Vec3 {
605        new_vertex
606            .get(&id)
607            .copied()
608            .unwrap_or_else(|| solid.vertices.iter().find(|v| v.id == id).map(|v| v.point).unwrap_or_default())
609    };
610    let old_point = |id: u64| -> Vec3 { solid.vertices.iter().find(|v| v.id == id).map(|v| v.point).unwrap_or_default() };
611    let mut result = solid.clone();
612    let mut changed_edges: HashSet<u64> = HashSet::default();
613    for edge in &mut result.edges {
614        if edge.degenerate {
615            continue;
616        }
617        let uses = faces_of_edge[&edge.id].clone();
618        let moved_ends = [edge.start_vertex_id, edge.end_vertex_id]
619            .iter()
620            .any(|id| point_of(*id).sub(old_point(*id)).length() > tolerance);
621        let drafted_here = uses.iter().any(|f| selected.contains(f));
622        if !drafted_here && !moved_ends {
623            continue;
624        }
625        let (start, end) = (point_of(edge.start_vertex_id), point_of(edge.end_vertex_id));
626        let mid_t = (edge.t0 + edge.t1) * 0.5;
627        let old_mid = edge.curve.evaluate(mid_t).or_refuse(KernelStage::Refine, "evaluate")?;
628        let on_new = |point: Vec3| uses.iter().all(|f| carriers.get(f).map_or(false, |c| c.residual(point).abs() <= accept));
629        if !moved_ends && on_new(old_mid) {
630            // Its faces' new carriers still contain it (an edge on the neutral
631            // plane, which every drafted carrier keeps): it stays as it is.
632            let quarter = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25).or_refuse(KernelStage::Refine, "evaluate")?;
633            if on_new(quarter) {
634                continue;
635            }
636        }
637        changed_edges.insert(edge.id);
638        if let Some((a, b)) = edge.curve.straight_segment(tolerance) {
639            let old_direction = b.sub(a);
640            let new_direction = end.sub(start);
641            if new_direction.length() <= tolerance || new_direction.dot(old_direction) <= 0.0 {
642                return Err(KernelRefusal::unsupported(KernelStage::Refine, CONSUMES_FACE, format!(
643                    "{OP}: the draft turns edge {} end over end (or collapses it) between faces \
644                     {uses:?}: it consumes a face, a change of topology the in-place draft does \
645                     not make — refusing",
646                    edge.id
647                )));
648            }
649            let curve = make_line(start, end).or_refuse(KernelStage::Sew, "make_line")?;
650            let mid = curve.evaluate(0.5).or_refuse(KernelStage::Sew, "evaluate")?;
651            if !on_new(mid) {
652                return Err(KernelRefusal::internal(KernelStage::Validate, "draft_edge_off_carrier", format!(
653                    "{OP}: the rebuilt straight edge {} leaves the carriers of faces {uses:?} — refusing",
654                    edge.id
655                )));
656            }
657            edge.curve = curve;
658            edge.t0 = 0.0;
659            edge.t1 = 1.0;
660            continue;
661        }
662        // A curved edge: the section of a cylinder or cone by a plane.
663        let pair = match (carriers.get(&uses[0]), carriers.get(&uses[1])) {
664            (Some(Carrier::Plane { origin, normal }), Some(Carrier::Ruled { frame, rho0, rho1, height }))
665            | (Some(Carrier::Ruled { frame, rho0, rho1, height }), Some(Carrier::Plane { origin, normal })) => {
666                Some((*origin, *normal, frame.clone(), *rho0, *rho1, *height))
667            }
668            _ => None,
669        };
670        let Some((origin, normal, frame, rho0, rho1, height)) = pair else {
671            return Err(KernelRefusal::unsupported(KernelStage::Refine, "draft_curved_edge", format!(
672                "{OP}: curved edge {} between faces {uses:?} is not the section of a cylinder or \
673                 cone by a plane, the only curved edge the draft re-intersects — refusing",
674                edge.id
675            )));
676        };
677        let sweep = swept_azimuth(&frame, &edge.curve, edge.t0, edge.t1)?;
678        let azimuth = |point: Vec3| {
679            let w = point.sub(frame.origin);
680            let a = w.dot(frame.y_axis).atan2(w.dot(frame.x_axis));
681            if a < 0.0 { a + std::f64::consts::TAU } else { a }
682        };
683        let closed = edge.start_vertex_id == edge.end_vertex_id;
684        let sweep = if closed { std::f64::consts::TAU.copysign(sweep) } else { sweep };
685        let (from, magnitude) = if sweep > 0.0 { (azimuth(start), sweep) } else { (azimuth(end), -sweep) };
686        // The sweep between the NEW ends: the old one corrected by how far each
687        // end turned about the axis (a section keeps its corners' azimuths
688        // only when they ride rulings of this carrier).
689        let magnitude = if closed {
690            magnitude
691        } else {
692            let (a0, a1) = if sweep > 0.0 { (azimuth(start), azimuth(end)) } else { (azimuth(end), azimuth(start)) };
693            let mut span = a1 - a0;
694            while span <= 0.0 {
695                span += std::f64::consts::TAU;
696            }
697            if (span - magnitude).abs() > std::f64::consts::PI {
698                magnitude
699            } else {
700                span
701            }
702        };
703        let mut curve = plane_ruled_section_arc(origin, normal, &frame, rho0, rho1, height, from, magnitude, tolerance)
704            .map_err(|error| {
705                KernelRefusal::unsupported(KernelStage::Refine, "draft_section", format!(
706                    "{OP}: edge {} between faces {uses:?}: {error}", edge.id
707                ))
708            })?;
709        if sweep < 0.0 {
710            curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
711        }
712        let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
713        let (c0, c1) = (
714            curve.evaluate(d0).or_refuse(KernelStage::Sew, "evaluate")?,
715            curve.evaluate(d1).or_refuse(KernelStage::Sew, "evaluate")?,
716        );
717        if c0.sub(start).length() > accept || c1.sub(end).length() > accept {
718            return Err(KernelRefusal::internal(KernelStage::Validate, "draft_section_ends", format!(
719                "{OP}: the re-intersected section of edge {} misses its corners by {:.3e} and \
720                 {:.3e} — refusing",
721                edge.id,
722                c0.sub(start).length(),
723                c1.sub(end).length()
724            )));
725        }
726        edge.curve = curve;
727        edge.t0 = d0;
728        edge.t1 = d1;
729    }
730    for vertex in &mut result.vertices {
731        if let Some(point) = new_vertex.get(&vertex.id) {
732            vertex.point = *point;
733        }
734    }
735    let final_edges: HashMap<u64, EdgeRecord> = result.edges.iter().map(|edge| (edge.id, edge.clone())).collect();
736
737    // --- Re-trim every face the draft touches -------------------------------
738    let mut ruled_retrims = Vec::new();
739    for shell in &mut result.shells {
740        for face in &mut shell.faces {
741            let touched = face
742                .loops
743                .iter()
744                .flat_map(|l| l.coedges.iter())
745                .any(|c| changed_edges.contains(&c.edge_id));
746            match drafts.get(&face.id) {
747                Some(Draft::Plane { old, rotation, .. }) => {
748                    let turned = Plane {
749                        origin: rotation.point(old.origin),
750                        u_dir: super::face_rotate::rotate_direction(rotation, old.u_dir),
751                        v_dir: super::face_rotate::rotate_direction(rotation, old.v_dir),
752                        normal: super::face_rotate::rotate_direction(rotation, old.normal),
753                    };
754                    retrim_planar_face(face, &turned, &final_edges, scale, OP)?;
755                }
756                Some(Draft::Cylinder { axis_point, radius, sign }) => {
757                    let surface = drafted_cylinder(&face.surface, pull, *axis_point, *radius, *sign, tan, face.id)?;
758                    let carrier = &carriers[&face.id];
759                    let [u0, u1] = surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
760                    let [v0, v1] = surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
761                    for i in 0..=4 {
762                        for j in 0..=4 {
763                            let p = surface
764                                .evaluate(u0 + (u1 - u0) * i as f64 / 4.0, v0 + (v1 - v0) * j as f64 / 4.0)
765                                .or_refuse(KernelStage::Refine, "evaluate")?;
766                            if carrier.residual(p).abs() > accept {
767                                return Err(KernelRefusal::internal(KernelStage::Validate, "draft_cone_net", format!(
768                                    "{OP}: the drafted net of face {} leaves its cone by {:.3e} — refusing",
769                                    face.id,
770                                    carrier.residual(p).abs()
771                                )));
772                            }
773                        }
774                    }
775                    rebuild_loop_pcurves(face, &final_edges, &surface, tolerance, OP)?;
776                    face.surface = surface;
777                }
778                None if touched => match &carriers.get(&face.id) {
779                    Some(Carrier::Plane { .. }) => {
780                        let plane = plane_of_surface(&face.surface, plane_tolerance, OP)?;
781                        retrim_planar_face(face, &plane, &final_edges, scale, OP)?;
782                    }
783                    Some(Carrier::Ruled { .. }) => ruled_retrims.push(face.id),
784                    None => {
785                        return Err(KernelRefusal::internal(KernelStage::Sew, "draft_unclassified", format!(
786                            "{OP}: face {} lost an edge to the draft but has no carrier — refusing",
787                            face.id
788                        )));
789                    }
790                },
791                None => {}
792            }
793        }
794    }
795    for face_id in ruled_retrims {
796        retrim_ruled_face(&mut result, face_id, &final_edges, tolerance, OP)?;
797    }
798    result.mass_properties_cache = Default::default();
799    accept_result(solid, &result)?;
800    Ok(result)
801}
802
803/// The cylinder's own net, made the drafted cone: every control point at
804/// height `h` above the neutral plane is scaled about the axis by
805/// `(r₀ − σ·h·tan α)/r₀`. Exact for a net whose rows along the circle each lie
806/// at one height (an extruded arc, a revolved line): each row is a circle
807/// scaled about its own centre, and the straight blend between rows is then the
808/// cone's ruling. Verified on the result by the caller.
809fn drafted_cylinder(
810    surface: &NurbsSurface,
811    pull: Vec3,
812    axis_point: Vec3,
813    radius: f64,
814    sign: f64,
815    tan: f64,
816    face_id: u64,
817) -> Result<NurbsSurface, KernelRefusal> {
818    let mut rows = surface.control_points.clone();
819    for row in &mut rows {
820        for control in row.iter_mut() {
821            let point = control.point().or_refuse(KernelStage::Refine, "point")?;
822            let w = point.sub(axis_point);
823            let h = w.dot(pull);
824            let q = w.sub(pull.scale(h));
825            let r = radius - sign * h * tan;
826            if r <= 0.0 {
827                return Err(KernelRefusal::unsupported(KernelStage::Refine, "draft_cone_apex", format!(
828                    "{OP}: the cone drafted from face {face_id} reaches its apex within the face's \
829                     carrier (radius {r:.6e} at height {h:.6e}) — refusing"
830                )));
831            }
832            let moved = axis_point.add(pull.scale(h)).add(q.scale(r / radius));
833            *control = Vec4::from_point(moved, control.w);
834        }
835    }
836    NurbsSurface::new(
837        surface.degree_u,
838        surface.degree_v,
839        surface.knots_u.clone(),
840        surface.knots_v.clone(),
841        rows,
842    )
843    .or_refuse(KernelStage::Refine, "drafted_cone")
844}
845
846/// The same acceptance floor `rotate_faces` holds a re-trimmed body to.
847fn accept_result(input: &BrepSolid, result: &BrepSolid) -> Result<(), KernelRefusal> {
848    let report = result.validate_detailed(&crate::KernelTolerances::for_solid(result, 1e-7));
849    if !report.issues.is_empty() {
850        return Err(KernelRefusal::internal(KernelStage::Validate, "validate", format!(
851            "{OP}: the drafted solid failed validation: {:?}", report.issues
852        )));
853    }
854    let crossings = crate::loop_self_crossings(result);
855    if !crossings.unreadable.is_empty() || crossings.loops == 0 {
856        return Err(KernelRefusal::internal(KernelStage::Validate, "crossing_scan_unreadable", format!(
857            "{OP}: the loop self-crossing scan could not read the drafted body ({:?}) — refusing",
858            crossings.unreadable
859        )));
860    }
861    if crossings.is_flagged() {
862        return Err(KernelRefusal::unsupported(KernelStage::Validate, CONSUMES_FACE, format!(
863            "{OP}: the draft makes a face's own loop cross itself ({}) — refusing",
864            crossings.summary().unwrap_or_default()
865        )));
866    }
867    if let (Ok(before), Ok(after)) =
868        (crate::signed_volume_against(input, 0.0), crate::signed_volume_against(result, 0.0))
869    {
870        if before * after <= 0.0 {
871            return Err(KernelRefusal::unsupported(KernelStage::Validate, CONSUMES_FACE, format!(
872                "{OP}: the draft inverts the solid (signed volume changed sign) — refusing"
873            )));
874        }
875    }
876    if !report.wire_warnings.is_empty() {
877        let inherited = input.validate_detailed(&crate::KernelTolerances::for_solid(input, 1e-7)).wire_warnings;
878        if report.wire_warnings.len() > inherited.len() {
879            return Err(KernelRefusal::internal(KernelStage::Validate, "chart_winding", format!(
880                "{OP}: the draft leaves a face whose (u,v) chart is left-handed against its own \
881                 normal ({}) — refusing",
882                report.wire_warnings.iter().map(|w| w.message.as_str()).collect::<Vec<_>>().join("; ")
883            )));
884        }
885    }
886    Ok(())
887}
888
889/// THE CONVEX ROUTE. A convex polyhedron is the intersection of its faces'
890/// half-spaces, so its draft is the intersection of the same half-spaces with
891/// the drafted faces' planes turned — whatever faces that leaves, which is
892/// what lets a steep draft consume a face (a box's top drafted away into a hip
893/// roof). Taken only for a body all of whose faces are planes and every vertex
894/// of which lies inside every face plane (that is convexity, for a closed
895/// polyhedral body), and only when every selected face is a plane.
896fn draft_convex_polyhedron(
897    solid: &BrepSolid,
898    face_ids: &[u64],
899    pull_direction: Vec3,
900    neutral_point: Vec3,
901    angle_radians: f64,
902) -> Result<BrepSolid, String> {
903    let pull = pull_direction.normalized()?;
904    let scale = solid_model_scale(solid);
905    let plane_tolerance = (scale * 1e-6).max(1e-7);
906    let selected: HashSet<u64> = face_ids.iter().copied().collect();
907    let (sin, cos) = angle_radians.sin_cos();
908    let mut half_spaces: Vec<(Vec3, Vec3)> = Vec::new();
909    for shell in &solid.shells {
910        for face in &shell.faces {
911            let plane = plane_of_surface(&face.surface, plane_tolerance, OP)
912                .map_err(|_| format!("face {} is not planar", face.id))?;
913            let outward = plane.normal.scale(outward_sign(face));
914            if selected.contains(&face.id) {
915                let height = plane.origin.sub(neutral_point).dot(pull);
916                let on_hinge = plane.origin.sub(pull.scale(height));
917                half_spaces.push((on_hinge, outward.scale(cos).add(pull.scale(sin)).normalized()?));
918            } else {
919                half_spaces.push((plane.origin, outward));
920            }
921        }
922    }
923    if solid.shells.len() != 1 {
924        return Err("the body has more than one shell".into());
925    }
926    for shell in &solid.shells {
927        for face in &shell.faces {
928            let plane = plane_of_surface(&face.surface, plane_tolerance, OP).map_err(|e| e.to_string())?;
929            let outward = plane.normal.scale(outward_sign(face));
930            if solid.vertices.iter().any(|v| v.point.sub(plane.origin).dot(outward) > plane_tolerance * 10.0) {
931                return Err(format!("the body is not convex (a vertex lies outside face {})", face.id));
932            }
933        }
934    }
935    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
936    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
937    for v in &solid.vertices {
938        low = Vec3::new(low.x.min(v.point.x), low.y.min(v.point.y), low.z.min(v.point.z));
939        high = Vec3::new(high.x.max(v.point.x), high.y.max(v.point.y), high.z.max(v.point.z));
940    }
941    let extent = high.sub(low).length();
942    let margin = extent * (1.0 + angle_radians.tan().abs()) + 1.0;
943    let corner = low.sub(Vec3::new(margin, margin, margin));
944    let size = high.sub(low).add(Vec3::new(2.0 * margin, 2.0 * margin, 2.0 * margin));
945    let mut body = crate::make_box_brep(corner, size.x, size.y, size.z)?;
946    for (origin, normal) in &half_spaces {
947        match crate::split_solid_by_plane(&body, *origin, *normal) {
948            Ok((inside, _)) => body = inside,
949            Err(error) if error.contains("does not intersect") => {
950                if body.vertices.iter().all(|v| v.point.sub(*origin).dot(*normal) >= -plane_tolerance) {
951                    return Err("the drafted half-spaces leave nothing".into());
952                }
953            }
954            Err(error) => return Err(error),
955        }
956    }
957    // The enclosing box must not be part of the answer: an unbounded draft
958    // would leave one of its faces standing.
959    let inner_low = corner.add(Vec3::new(plane_tolerance, plane_tolerance, plane_tolerance).scale(1e3));
960    let inner_high = corner.add(size).sub(Vec3::new(plane_tolerance, plane_tolerance, plane_tolerance).scale(1e3));
961    if body.vertices.iter().any(|v| {
962        v.point.x <= inner_low.x || v.point.y <= inner_low.y || v.point.z <= inner_low.z
963            || v.point.x >= inner_high.x || v.point.y >= inner_high.y || v.point.z >= inner_high.z
964    }) {
965        return Err("the drafted half-spaces do not bound a solid".into());
966    }
967    accept_result(solid, &body).map_err(|e| e.to_string())?;
968    Ok(body)
969}
970
971/// A PARTING-LINE draft: the body is split by the neutral plane through
972/// `parting_point` square to `pull_direction`, the pieces of the selected faces
973/// on the `+pull` side are drafted for `+pull` and those on the other side for
974/// `−pull` — each leaning `angle_radians` away from the parting line, as the
975/// two halves of a two-piece mould pull apart — and the two sides are united
976/// again along the parting plane. The selected faces must each be a plane
977/// parallel to the pull or a cylinder about it, as for [`draft_faces`].
978pub fn draft_faces_parted(
979    solid: &BrepSolid,
980    face_ids: &[u64],
981    pull_direction: Vec3,
982    parting_point: Vec3,
983    angle_radians: f64,
984) -> Result<BrepSolid, KernelRefusal> {
985    let pull = check_inputs(face_ids, pull_direction, parting_point, angle_radians)?;
986    let scale = solid_model_scale(solid);
987    let plane_tolerance = (scale * 1e-6).max(1e-7);
988    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
989    for (shell_index, shell) in solid.shells.iter().enumerate() {
990        for (face_index, face) in shell.faces.iter().enumerate() {
991            face_lookup.entry(face.id).or_insert((shell_index, face_index));
992        }
993    }
994    let mut wanted = Vec::new();
995    for &face_id in face_ids {
996        if !face_lookup.contains_key(&face_id) {
997            return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("{OP}: no face with id {face_id}")));
998        }
999        let carrier = carrier_of(solid, &face_lookup, face_id, pull, plane_tolerance).ok_or_else(|| {
1000            KernelRefusal::unsupported(KernelStage::Classify, "draft_carrier_kind", format!(
1001                "{OP}: face {face_id} is neither a plane parallel to the pull direction nor a \
1002                 cylinder whose axis is the pull direction — refusing"
1003            ))
1004        })?;
1005        wanted.push(carrier);
1006    }
1007    let (below, above) = crate::split_solid_by_plane(solid, parting_point, pull).map_err(|error| {
1008        KernelRefusal::unsupported(KernelStage::Classify, "draft_parting_plane", format!(
1009            "{OP}: the parting plane does not divide the body ({error}) — refusing"
1010        ))
1011    })?;
1012    let same = |a: &Carrier, b: &Carrier| -> bool {
1013        match (a, b) {
1014            (Carrier::Plane { origin: oa, normal: na }, Carrier::Plane { origin: ob, normal: nb }) => {
1015                na.cross(*nb).length() <= 1e-9 && ob.sub(*oa).dot(*na).abs() <= plane_tolerance
1016            }
1017            (
1018                Carrier::Ruled { frame: fa, rho0: ra0, rho1: ra1, height: ha },
1019                Carrier::Ruled { frame: fb, rho0: rb0, rho1: rb1, height: hb },
1020            ) => {
1021                let offset = fb.origin.sub(fa.origin);
1022                fa.axis.cross(fb.axis).length() <= 1e-9
1023                    && offset.sub(fa.axis.scale(offset.dot(fa.axis))).length() <= plane_tolerance
1024                    && ((ra1 - ra0) / ha).abs() <= 1e-9
1025                    && ((rb1 - rb0) / hb).abs() <= 1e-9
1026                    && (ra0 - rb0).abs() <= plane_tolerance
1027            }
1028            _ => false,
1029        }
1030    };
1031    let mut halves = Vec::new();
1032    for (piece, side_pull) in [(above, pull), (below, pull.scale(-1.0))] {
1033        let mut lookup: HashMap<u64, (usize, usize)> = HashMap::default();
1034        for (shell_index, shell) in piece.shells.iter().enumerate() {
1035            for (face_index, face) in shell.faces.iter().enumerate() {
1036                lookup.entry(face.id).or_insert((shell_index, face_index));
1037            }
1038        }
1039        let mut ids: Vec<u64> = lookup
1040            .keys()
1041            .copied()
1042            .filter(|&id| {
1043                carrier_of(&piece, &lookup, id, pull, plane_tolerance)
1044                    .map_or(false, |c| wanted.iter().any(|w| same(w, &c)))
1045            })
1046            .collect();
1047        ids.sort_unstable();
1048        if ids.is_empty() {
1049            return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_parting_side", format!(
1050                "{OP}: no selected face reaches one side of the parting plane — refusing"
1051            )));
1052        }
1053        halves.push(draft_faces(&piece, &ids, side_pull, parting_point, angle_radians)?);
1054    }
1055    crate::boolean_operation(
1056        &halves[0],
1057        &halves[1],
1058        crate::BooleanOperation::Union,
1059        &crate::BooleanOptions::default(),
1060    )
1061}