Skip to main content

brep_cam/features/
recognize.rs

1//! The recognition rules (see the module documentation of [`super`]).
2
3use super::accounting::{BodyReason, FaceAccount, Refusal};
4use super::graph::{Adjacency, FaceGraph};
5use super::{Carrier, FaceCarrier};
6use crate::holes::{Hole, Recognition};
7use crate::mesh::MeshSnapshot;
8use crate::model::{Point2, Point3};
9use crate::numeric::Tolerance;
10use crate::planar::extract::chain_undirected;
11use crate::planar::machining::PlanarRegion;
12use crate::planar::{simplified, Chain, Loop, Region, Segment, Vec2};
13use crate::CamError;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17/// What recognition reads. Regions are keyed by face name and come from
18/// `kernel_adapter::planar::horizontal_faces` (planes perpendicular to the
19/// tool axis); carriers from `kernel_adapter::features::face_carriers`.
20pub struct RecognitionInput<'a> {
21    pub mesh: &'a MeshSnapshot,
22    pub carriers: &'a [FaceCarrier],
23    pub regions: &'a [(String, PlanarRegion)],
24    pub holes: &'a Recognition,
25    pub tolerance: Tolerance,
26}
27
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "camelCase")]
30pub enum FeatureKind {
31    /// Outside silhouette, excluding holes, measured in machine coordinates.
32    OuterProfile { outline: Region, top_z: f64, bottom_z: f64, walls: ProfileWalls },
33    /// A top face to face off.
34    Face { region: Region, z: f64 },
35    /// `open` when part of the outer boundary drops away; `wall_chains`
36    /// counts the connected runs of walls. `open_edges` are the outer
37    /// boundary parts that drop away (where the tool may run out), `walls`
38    /// the planar walls as XY lines with their inward normal.
39    Pocket {
40        floor: Region,
41        floor_z: f64,
42        top_z: f64,
43        open: bool,
44        wall_chains: u32,
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        corner_radius: Option<f64>,
47        islands: u32,
48        #[serde(default, skip_serializing_if = "Vec::is_empty")]
49        open_edges: Vec<Chain>,
50        #[serde(default, skip_serializing_if = "Vec::is_empty")]
51        walls: Vec<WallLine>,
52    },
53    /// An open pocket with one wall chain.
54    Step {
55        floor: Region,
56        floor_z: f64,
57        top_z: f64,
58        wall_length: f64,
59        #[serde(default, skip_serializing_if = "Vec::is_empty")]
60        open_edges: Vec<Chain>,
61        #[serde(default, skip_serializing_if = "Vec::is_empty")]
62        walls: Vec<WallLine>,
63    },
64    /// A closed pocket whose outline is a stadium.
65    Slot { centre: Chain, width: f64, floor_z: f64, top_z: f64 },
66    /// Material standing on a floor (`base_z`) up to `top_z`.
67    Boss {
68        outline: Region,
69        top_z: f64,
70        base_z: f64,
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        corner_radius: Option<f64>,
73    },
74    Hole(Hole),
75    /// `width` is the horizontal leg; `chain` is the edge the chamfer
76    /// replaces: the wall line at the top height `z`, directed with the part
77    /// interior on its left (what `chamfer2d` expects for an open chain).
78    Chamfer { chain: Chain, width: f64, z: f64, length: f64 },
79    /// A convex rounded edge; `chain` is its tangent edge with the face above.
80    Fillet { chain: Chain, radius: f64, z: f64, length: f64 },
81}
82
83/// Classification of the outside walls reached from the bottom boundary.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all="camelCase")]
86pub enum ProfileWalls { Vertical, Drafted, Undercut, Uncertified }
87
88/// A planar wall of a floor as a line in machine XY: a point on it and the
89/// unit normal pointing into the floor (away from the wall's material).
90#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct WallLine {
93    pub point: Point2,
94    pub inward: Point2,
95}
96
97impl FeatureKind {
98    pub fn name(&self) -> &'static str {
99        match self {
100            FeatureKind::OuterProfile { .. } => "outer-profile",
101            FeatureKind::Face { .. } => "face",
102            FeatureKind::Pocket { open: false, .. } => "pocket",
103            FeatureKind::Pocket { open: true, .. } => "open-pocket",
104            FeatureKind::Step { .. } => "step",
105            FeatureKind::Slot { .. } => "slot",
106            FeatureKind::Boss { .. } => "boss",
107            FeatureKind::Hole(_) => "hole",
108            FeatureKind::Chamfer { .. } => "chamfer",
109            FeatureKind::Fillet { .. } => "fillet",
110        }
111    }
112}
113
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct Feature {
117    pub id: String,
118    /// Persistent body name.
119    pub solid: String,
120    pub kind: FeatureKind,
121    /// Persistent face names the feature is made of (floor and walls, the
122    /// chamfer face, the hole walls, ...).
123    pub faces: Vec<String>,
124    /// Direction the tool approaches along (machine space, unit).
125    pub direction: Point3,
126    pub machinable: bool,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub reason: Option<String>,
129}
130
131#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct Recognized {
134    pub features: Vec<Feature>,
135    /// Faces that belong to no feature, with why.
136    pub unassigned: Vec<(String, String)>,
137    /// One entry per face of every target body (see [`super::accounting`]).
138    /// `None` (documents written before accounting) means unknown.
139    #[serde(default)]
140    pub accounting: Option<Vec<FaceAccount>>,
141}
142
143/// What recognition noted while declining candidates.
144#[derive(Default)]
145struct Notes {
146    refusals: BTreeMap<u32, Vec<Refusal>>,
147    /// Floors taken as the bottom of a recognised hole: (face, hole index).
148    hole_bottoms: Vec<(u32, usize)>,
149}
150
151impl Notes {
152    fn refuse(&mut self, face: u32, candidate: &str, reason: impl Into<String>) {
153        self.refusals.entry(face).or_default().push(Refusal { candidate: candidate.to_string(), reason: reason.into() });
154    }
155}
156
157/// Classification of a face's carrier against the tool axis.
158#[derive(Clone, Copy, Debug, PartialEq)]
159enum Class {
160    /// Plane with its outward unit normal.
161    Plane(Point3),
162    VerticalCylinder(f64),
163    HorizontalCylinder(f64),
164    Other,
165}
166
167struct Ctx<'a> {
168    input: &'a RecognitionInput<'a>,
169    graph: FaceGraph,
170    class: Vec<Class>,
171    carrier: Vec<Option<&'a Carrier>>,
172    region: Vec<Option<&'a PlanarRegion>>,
173    /// Hole index per face.
174    hole_of: Vec<Option<usize>>,
175}
176
177fn name_of(mesh: &MeshSnapshot, f: u32) -> String {
178    mesh.faces[f as usize].face.clone()
179}
180
181/// Recognise the 2.5D features of the snapshot's target bodies.
182pub fn recognize(input: &RecognitionInput<'_>) -> Result<Recognized, CamError> {
183    let tol = input.tolerance;
184    tol.validate()?;
185    let mesh = input.mesh;
186    if mesh.is_empty() {
187        return Err(CamError::InvalidInput("feature recognition needs a mesh".into()));
188    }
189    let graph = FaceGraph::build(mesh, &tol);
190    let n = mesh.faces.len();
191    let by_name: BTreeMap<(&str, &str), &Carrier> = input.carriers.iter().map(|c| ((c.solid.as_str(), c.face.as_str()), &c.carrier)).collect();
192    let regions: BTreeMap<&str, &PlanarRegion> = input.regions.iter().map(|(n, r)| (n.as_str(), r)).collect();
193    let mut carrier = Vec::with_capacity(n);
194    let mut region = Vec::with_capacity(n);
195    let mut class = Vec::with_capacity(n);
196    for f in &mesh.faces {
197        let c = by_name.get(&(f.solid.as_str(), f.face.as_str())).copied();
198        carrier.push(c);
199        region.push(regions.get(f.face.as_str()).copied());
200        class.push(match c {
201            Some(Carrier::Plane { normal, .. }) => normal.normalized().map_or(Class::Other, Class::Plane),
202            Some(Carrier::Cylinder { axis, radius, .. }) => {
203                if tol_vertical(*axis, &tol) {
204                    Class::VerticalCylinder(*radius)
205                } else if axis.z.abs() <= 1e-6 {
206                    Class::HorizontalCylinder(*radius)
207                } else {
208                    Class::Other
209                }
210            }
211            _ => Class::Other,
212        });
213    }
214    let mut hole_of = vec![None; n];
215    for (hi, h) in input.holes.holes.iter().enumerate() {
216        for (fi, f) in mesh.faces.iter().enumerate() {
217            if f.solid == h.solid && h.faces.contains(&f.face) {
218                hole_of[fi] = Some(hi);
219            }
220        }
221    }
222    let ctx = Ctx { input, graph, class, carrier, region, hole_of };
223    let mut out = Recognized::default();
224    let mut notes = Notes::default();
225    let mut assigned = vec![false; n];
226    // Holes first: their walls are never pocket walls.
227    for (hi, h) in input.holes.holes.iter().enumerate() {
228        let _ = hi;
229        let reason = if h.machinable { None } else { Some("the hole axis is not the tool axis; it needs another setup".to_string()) };
230        out.features.push(Feature {
231            id: String::new(),
232            solid: h.solid.clone(),
233            kind: FeatureKind::Hole(h.clone()),
234            faces: h.faces.clone(),
235            direction: h.axis,
236            machinable: h.machinable,
237            reason,
238        });
239        for (fi, f) in mesh.faces.iter().enumerate() {
240            if f.solid == h.solid && h.faces.contains(&f.face) {
241                assigned[fi] = true;
242            }
243        }
244    }
245    let top_z = ctx
246        .graph
247        .faces
248        .iter()
249        .filter(|s| matches!(ctx.class[s.face as usize], Class::Plane(nn) if nn.z > 1.0 - 1e-6))
250        .map(|s| s.z_max)
251        .fold(f64::NEG_INFINITY, f64::max);
252    // Floors: the upward planar faces first (their walls are then taken), then
253    // every other planar face, which can only report a feature of another
254    // setup; each pass in face index order.
255    for upward_pass in [true, false] {
256        for f in 0..n as u32 {
257            if mesh.faces[f as usize].protected_only || assigned[f as usize] {
258                continue;
259            }
260            let Class::Plane(normal) = ctx.class[f as usize] else { continue };
261            if (normal.z > 1.0 - 1e-6) != upward_pass {
262                continue;
263            }
264            if let Some(feats) = floor_features(&ctx, f, normal, top_z, &mut assigned, &mut notes)? {
265                out.features.extend(feats);
266            }
267        }
268    }
269    // Chamfers and fillets on top edges.
270    for f in 0..n as u32 {
271        if mesh.faces[f as usize].protected_only || assigned[f as usize] {
272            continue;
273        }
274        match ctx.class[f as usize] {
275            Class::Plane(normal) if normal.z > 1e-6 && normal.z < 1.0 - 1e-6 && normal.x.hypot(normal.y) > 1e-6 => {
276                match chamfer_feature(&ctx, f, normal) {
277                    Ok(feat) => {
278                        assigned[f as usize] = true;
279                        out.features.push(feat);
280                    }
281                    Err(why) => notes.refuse(f, "chamfer", why),
282                }
283            }
284            Class::HorizontalCylinder(radius) => {
285                match fillet_feature(&ctx, f, radius) {
286                    Ok(feat) => {
287                        assigned[f as usize] = true;
288                        out.features.push(feat);
289                    }
290                    Err(why) => notes.refuse(f, "fillet", why),
291                }
292            }
293            _ => {}
294        }
295    }
296    let profiles=outer_profiles(&ctx)?;
297    for profile in &profiles {for (i,face) in mesh.faces.iter().enumerate() {if face.solid==profile.solid && profile.faces.contains(&face.face) {assigned[i]=true;}}}
298    out.features.extend(profiles);
299    for (i, feat) in out.features.iter_mut().enumerate() {
300        feat.id = format!("feature-{}", i + 1);
301    }
302    for f in 0..n {
303        if !assigned[f] && !mesh.faces[f].protected_only {
304            out.unassigned.push((mesh.faces[f].face.clone(), describe(ctx.class[f]).to_string()));
305        }
306    }
307    out.accounting = Some(account(&ctx, &out.features, notes));
308    Ok(out)
309}
310
311/// What a face is relative to the tool axis (the `unassigned` wording).
312fn describe(class: Class) -> &'static str {
313    match class {
314        Class::Plane(nn) if nn.z < -1.0 + 1e-6 => "a horizontal face facing away from the tool",
315        Class::Plane(nn) if nn.z.abs() <= 1e-6 => "a vertical wall",
316        Class::Plane(_) => "an inclined face that is not a chamfer",
317        Class::VerticalCylinder(_) => "a vertical cylinder that is neither a hole nor a pocket corner",
318        Class::HorizontalCylinder(_) => "a horizontal cylinder that is not a top-edge fillet",
319        Class::Other => "a surface with no 2.5D carrier",
320    }
321}
322
323/// [`describe`], naming an upward plane (which `describe` files under
324/// inclined faces, a wording kept for `unassigned` compatibility).
325fn describe_face(class: Class) -> &'static str {
326    match class {
327        Class::Plane(nn) if nn.z > 1.0 - 1e-6 => "a horizontal face facing the tool",
328        _ => describe(class),
329    }
330}
331
332/// Build the face accounting (see [`super::accounting`]). Feature claims come
333/// from the features' face lists plus the floors taken as hole bottoms; body
334/// reasons are positive tests against the body's machine-axis bounding box.
335fn account(ctx: &Ctx<'_>, features: &[Feature], mut notes: Notes) -> Vec<FaceAccount> {
336    let mesh = ctx.input.mesh;
337    let tol = &ctx.input.tolerance;
338    let n = mesh.faces.len();
339    // Machine-axis bounds per solid, over target faces only.
340    let mut bounds: BTreeMap<&str, (Point3, Point3)> = BTreeMap::new();
341    for t in &mesh.triangles {
342        let src = &mesh.faces[t.face as usize];
343        if src.protected_only {
344            continue;
345        }
346        let e = bounds.entry(src.solid.as_str()).or_insert((Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)));
347        for v in t.vertices() {
348            e.0 = Point3::new(e.0.x.min(v.x), e.0.y.min(v.y), e.0.z.min(v.z));
349            e.1 = Point3::new(e.1.x.max(v.x), e.1.y.max(v.y), e.1.z.max(v.z));
350        }
351    }
352    let mut claims: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
353    for feat in features {
354        for face in &feat.faces {
355            claims.entry((feat.solid.as_str(), face.as_str())).or_default().push(feat.id.clone());
356        }
357    }
358    let hole_ids: Vec<&Feature> = features.iter().filter(|f| matches!(f.kind, FeatureKind::Hole(_))).collect();
359    for &(f, hi) in &notes.hole_bottoms {
360        if let Some(h) = hole_ids.get(hi) {
361            let src = &mesh.faces[f as usize];
362            claims.entry((src.solid.as_str(), src.face.as_str())).or_default().push(h.id.clone());
363        }
364    }
365    // Faces hole recognition declined, by "solid/face: reason".
366    for (f, src) in mesh.faces.iter().enumerate() {
367        let prefix = format!("{}/{}: ", src.solid, src.face);
368        for r in &ctx.input.holes.rejected {
369            if let Some(reason) = r.strip_prefix(&prefix) {
370                notes.refuse(f as u32, "hole", reason);
371            }
372        }
373    }
374    let eps = tol.distance_eps.max(1e-6) * 10.0;
375    let mut out = Vec::new();
376    for f in 0..n {
377        let src = &mesh.faces[f];
378        if src.protected_only {
379            continue;
380        }
381        let mut a = FaceAccount::new(src.solid.clone(), src.face.clone(), describe_face(ctx.class[f]).to_string());
382        a.features = claims.remove(&(src.solid.as_str(), src.face.as_str())).unwrap_or_default();
383        a.refusals = notes.refusals.remove(&(f as u32)).unwrap_or_default();
384        let stats = &ctx.graph.faces[f];
385        if let (Some((lo, hi)), Class::Plane(nn)) = (bounds.get(src.solid.as_str()), ctx.class[f]) {
386            let point = match ctx.carrier[f] {
387                Some(Carrier::Plane { point, .. }) => *point,
388                _ => stats.centroid,
389            };
390            a.body = if nn.z < -1.0 + 1e-6 && (stats.z_max - lo.z).abs() <= eps {
391                Some(BodyReason::StockBottom)
392            } else if nn.z > 1.0 - 1e-6 && (stats.z_min - hi.z).abs() <= eps {
393                Some(BodyReason::StockTop)
394            } else if nn.z.abs() <= 1e-6
395                && ((nn.x > 1.0 - 1e-6 && (point.x - hi.x).abs() <= eps)
396                    || (nn.x < -1.0 + 1e-6 && (point.x - lo.x).abs() <= eps)
397                    || (nn.y > 1.0 - 1e-6 && (point.y - hi.y).abs() <= eps)
398                    || (nn.y < -1.0 + 1e-6 && (point.y - lo.y).abs() <= eps))
399            {
400                Some(BodyReason::StockSide)
401            } else {
402                None
403            };
404        }
405        a.settle();
406        out.push(a);
407    }
408    out
409}
410
411fn tol_vertical(axis: Point3, tol: &Tolerance) -> bool {
412    axis.normalized().map_or(false, |a| a.z.abs() >= 1.0 - tol.angle_eps.max(1e-9))
413}
414
415/// Which loop of a region a point lies on (by nearest distance): `None`
416/// for the outer loop, `Some(k)` for the k-th hole loop.
417fn loop_of(region: &Region, p: Point2, tol: &Tolerance) -> Option<usize> {
418    let mut best: Option<(f64, usize)> = None;
419    for (i, l) in region.loops.iter().enumerate() {
420        let d = l.segments.iter().map(|s| s.distance(p)).fold(f64::INFINITY, f64::min);
421        if best.map_or(true, |(bd, _)| d < bd) {
422            best = Some((d, i));
423        }
424    }
425    let (d, i) = best?;
426    let _ = d;
427    let _ = tol;
428    if region.loops[i].is_ccw() {
429        None
430    } else {
431        Some(i)
432    }
433}
434
435/// How far a neighbour rises along the floor's normal.
436fn rise(ctx: &Ctx<'_>, floor: u32, normal: Point3, neighbour: u32) -> f64 {
437    let fs = &ctx.graph.faces[floor as usize];
438    let ns = &ctx.graph.faces[neighbour as usize];
439    if normal.z > 0.9 {
440        ns.z_max - fs.z_max
441    } else if normal.z < -0.9 {
442        fs.z_min - ns.z_min
443    } else {
444        ns.centroid.sub(fs.centroid).dot(normal)
445    }
446}
447
448struct Neighbour<'a> {
449    face: u32,
450    adj: &'a Adjacency,
451    /// Hole loop index of the floor's region, `None` on the outer loop.
452    island: Option<usize>,
453}
454
455/// Projection of the triangle portion strictly above a plane. Clipping close
456/// to a mesh vertex can introduce a sub-epsilon edge; merge equal points using
457/// the same policy that the planar engine uses to validate its segments.
458pub(crate) fn projected_above(triangle: &crate::mesh::Triangle, level: f64, tol: &Tolerance) -> Option<Loop> {
459    if triangle.max_z() <= level { return None; }
460    let vertices = triangle.vertices();
461    let mut points = Vec::new();
462    for i in 0..3 {
463        let (a, b) = (vertices[i], vertices[(i + 1) % 3]);
464        if (a.z > level) != (b.z > level) {
465            let p = a.lerp(b, (level - a.z) / (b.z - a.z));
466            points.push(Point2::new(p.x, p.y));
467        }
468        if b.z > level { points.push(Point2::new(b.x, b.y)); }
469    }
470    points.dedup_by(|a, b| a.distance(*b) <= tol.point_eps);
471    while points.len() > 1 && points[0].distance(*points.last().unwrap()) <= tol.point_eps { points.pop(); }
472    if points.len() < 3 { return None; }
473    let mut boundary = Loop::polygon(&points);
474    if boundary.signed_area().abs() <= tol.area_eps { return None; }
475    if !boundary.is_ccw() { boundary = boundary.reversed(); }
476    Some(boundary)
477}
478
479/// Certify the whole floor interior, not only its boundary, against overhead material.
480pub(crate) fn floor_access_refusal(floor: &Region, z: f64, mesh: &MeshSnapshot, regions: &[(String, PlanarRegion)], tol: &Tolerance) -> Option<String> {
481        let (lo, hi) = floor.bounds();
482        let level = z + tol.distance_eps;
483        // A mesh chord across a circular opening lies inside the exact hole.
484        // Use the exact trimmed planar face where supplied, so that sliver
485        // is not mistaken for a roof. Nonplanar faces still use the mesh.
486        let mut exact_faces = std::collections::BTreeSet::new();
487        for (fi, face) in mesh.faces.iter().enumerate() {
488            let mut matching = regions.iter().filter(|(name, _)| name == &face.face);
489            let Some((_, region)) = matching.next() else { continue; };
490            if matching.next().is_some() { continue; }
491            exact_faces.insert(fi as u32);
492            if region.z <= level { continue; }
493            let blocked = match floor.intersection(&region.region, tol) {
494                Ok(overlap) => overlap.area() > tol.area_eps,
495                Err(_) => true,
496            };
497            if blocked { return Some(format!("undercut floor approach blocked by {}/{}; no certified vertical access in this setup", face.solid, face.face)); }
498        }
499        for triangle in &mesh.triangles {
500            if exact_faces.contains(&triangle.face) { continue; }
501            if triangle.max_z() <= level || triangle.bounds.max.x <= lo.x || triangle.bounds.min.x >= hi.x
502                || triangle.bounds.max.y <= lo.y || triangle.bounds.min.y >= hi.y { continue; }
503            let Some(boundary) = projected_above(triangle, level, tol) else { continue; };
504            let blocked=match floor.intersection(&Region{loops:vec![boundary]},tol) {
505                Ok(overlap)=>overlap.area()>tol.area_eps,
506                Err(_)=>true,
507            };
508            if blocked {
509                let obstruction=&mesh.faces[triangle.face as usize];
510                return Some(format!("undercut floor approach blocked by {}/{}; no certified vertical access in this setup",obstruction.solid,obstruction.face));
511            }
512        }
513    None
514}
515
516fn floor_features(ctx: &Ctx<'_>, f: u32, normal: Point3, top_z: f64, assigned: &mut [bool], notes: &mut Notes) -> Result<Option<Vec<Feature>>, CamError> {
517    let tol = &ctx.input.tolerance;
518    let mesh = ctx.input.mesh;
519    let solid = mesh.faces[f as usize].solid.clone();
520    let stats = &ctx.graph.faces[f as usize];
521    let upward = normal.z > 1.0 - 1e-6;
522    let region = ctx.region[f as usize];
523    let neighbours: Vec<Neighbour<'_>> = ctx
524        .graph
525        .neighbours(f)
526        .into_iter()
527        .map(|(nf, adj)| {
528            let island = region.and_then(|r| {
529                let (a, b) = adj.segments[0];
530                let m = a.lerp(b, 0.5);
531                loop_of(&r.region, Point2::new(m.x, m.y), tol)
532            });
533            Neighbour { face: nf, adj, island }
534        })
535        .collect();
536    let mut wall_len = 0.0;
537    let mut drop_len = 0.0;
538    let mut walls: Vec<&Neighbour<'_>> = Vec::new();
539    let mut drops: Vec<&Neighbour<'_>> = Vec::new();
540    for nb in neighbours.iter().filter(|nb| nb.island.is_none()) {
541        if nb.adj.concave() && rise(ctx, f, normal, nb.face) > tol.distance_eps {
542            wall_len += nb.adj.length();
543            walls.push(nb);
544        } else if nb.adj.convex_length > 0.0 {
545            drop_len += nb.adj.length();
546            drops.push(nb);
547        }
548    }
549    let faces_of = |list: &[&Neighbour<'_>]| -> Vec<String> {
550        let mut v = vec![name_of(mesh, f)];
551        v.extend(list.iter().map(|nb| name_of(mesh, nb.face)));
552        v
553    };
554    // A floor whose walls all belong to one recognised hole is its bottom.
555    if !walls.is_empty() {
556        let holes: Vec<Option<usize>> = walls.iter().map(|w| ctx.hole_of[w.face as usize]).collect();
557        if holes.iter().all(|h| h.is_some()) && holes.windows(2).all(|w| w[0] == w[1]) {
558            assigned[f as usize] = true;
559            if let Some(hi) = holes[0] {
560                notes.hole_bottoms.push((f, hi));
561            }
562            return Ok(None);
563        }
564    }
565    if walls.is_empty() {
566        if !upward {
567            return Ok(None);
568        }
569        let Some(r) = region else {
570            notes.refuse(f, "face-or-boss", "the upward planar face has no exact planar region");
571            return Ok(None);
572        };
573        assigned[f as usize] = true;
574        let z = stats.z_max;
575        if (z - top_z).abs() <= tol.distance_eps || drops.is_empty() {
576            return Ok(Some(vec![Feature { id: String::new(), solid: solid.clone(), kind: FeatureKind::Face { region: r.region.clone(), z }, faces: vec![name_of(mesh, f)], direction: Point3::Z, machinable: true, reason: None }]));
577        }
578        // A raised face below the top: a boss standing on its drops' bottoms.
579        let base_z = drops.iter().map(|d| ctx.graph.faces[d.face as usize].z_min).fold(f64::INFINITY, f64::min);
580        let corner = drops.iter().filter_map(|d| if let Class::VerticalCylinder(rr) = ctx.class[d.face as usize] { Some(rr) } else { None }).fold(f64::INFINITY, f64::min);
581        let reason = boss_access_refusal(ctx, &r.region, z).or_else(|| boss_wall_refusal(ctx, &drops, base_z));
582        for d in &drops {
583            assigned[d.face as usize] = true;
584        }
585        return Ok(Some(vec![Feature {
586            id: String::new(),
587            solid: solid.clone(),
588            kind: FeatureKind::Boss { outline: r.region.clone(), top_z: z, base_z, corner_radius: (corner.is_finite()).then_some(corner) },
589            faces: faces_of(&drops),
590            direction: Point3::Z,
591            machinable: reason.is_none(),
592            reason,
593        }]));
594    }
595    // A floor with walls: pocket, step or slot, machinable only along +Z.
596    let top = walls.iter().map(|w| ctx.graph.faces[w.face as usize].z_max).fold(f64::NEG_INFINITY, f64::max);
597    let floor_z = stats.z_max;
598    let open = drop_len > tol.distance_eps;
599    let chains = wall_chains(ctx, &walls);
600    let open_edges: Vec<Chain> = drops.iter().filter_map(|d| shared_chain(d.adj, tol)).collect();
601    let wall_lines: Vec<WallLine> = walls
602        .iter()
603        .filter_map(|w| match ctx.carrier[w.face as usize] {
604            Some(Carrier::Plane { point, normal }) if normal.x.hypot(normal.y) > 1e-9 => {
605                let inward = Point2::new(normal.x, normal.y).normalized()?;
606                Some(WallLine { point: Point2::new(point.x, point.y), inward })
607            }
608            _ => None,
609        })
610        .collect();
611    let corner = walls.iter().filter_map(|w| if let Class::VerticalCylinder(rr) = ctx.class[w.face as usize] { Some(rr) } else { None }).fold(f64::INFINITY, f64::min);
612    let corner_radius = corner.is_finite().then_some(corner);
613    let mut faces = faces_of(&walls);
614    assigned[f as usize] = true;
615    // A floor inaccessible in this setup must not consume adjacent faces:
616    // one of those faces can be the accessible floor of a side setup.
617    if !upward {
618        let reason = if normal.z < -1.0 + 1e-6 {
619            "the floor faces away from the tool".to_string()
620        } else {
621            format!("the feature is accessed along ({:.3}, {:.3}, {:.3}); it needs a setup with the tool axis along it", normal.x, normal.y, normal.z)
622        };
623        let kind = if open && chains == 1 {
624            FeatureKind::Step { floor: Region::empty(), floor_z, top_z: top, wall_length: wall_len, open_edges: open_edges.clone(), walls: wall_lines.clone() }
625        } else {
626            FeatureKind::Pocket { floor: Region::empty(), floor_z, top_z: top, open, wall_chains: chains, corner_radius, islands: 0, open_edges: open_edges.clone(), walls: wall_lines.clone() }
627        };
628        return Ok(Some(vec![Feature { id: String::new(), solid: solid.clone(), kind, faces, direction: normal, machinable: false, reason: Some(reason) }]));
629    }
630    let Some(r) = region else {
631        return Ok(Some(vec![Feature {
632            id: String::new(),
633            solid: solid.clone(),
634            kind: FeatureKind::Pocket { floor: Region::empty(), floor_z, top_z: top, open, wall_chains: chains, corner_radius, islands: 0, open_edges, walls: wall_lines },
635            faces,
636            direction: Point3::Z,
637            machinable: false,
638            reason: Some("the floor has no exact planar region".to_string()),
639        }]));
640    };
641    let mut feats = Vec::new();
642    // An inaccessible floor is refused, but the islands standing on it are
643    // still bosses: each one is judged by its own access check below.
644    if let Some(reason) = floor_access_refusal(&r.region, floor_z, mesh, ctx.input.regions, tol) {
645        let kind = if open && chains == 1 {
646            FeatureKind::Step { floor: r.region.clone(), floor_z, top_z: top, wall_length: wall_len, open_edges, walls: wall_lines }
647        } else {
648            FeatureKind::Pocket { floor: r.region.clone(), floor_z, top_z: top, open, wall_chains: chains, corner_radius, islands: 0, open_edges, walls: wall_lines }
649        };
650        feats.push(Feature { id: String::new(), solid: solid.clone(), kind, faces: faces.clone(), direction: normal, machinable: false, reason: Some(reason) });
651    } else {
652        for w in &walls { assigned[w.face as usize] = true; }
653        let shells = r.region.shells(tol);
654        let islands = shells.iter().map(|s| s.holes.len()).sum::<usize>() as u32;
655        if open {
656            let kind = if chains == 1 {
657                FeatureKind::Step { floor: r.region.clone(), floor_z, top_z: top, wall_length: wall_len, open_edges: open_edges.clone(), walls: wall_lines.clone() }
658            } else {
659                FeatureKind::Pocket { floor: r.region.clone(), floor_z, top_z: top, open: true, wall_chains: chains, corner_radius, islands, open_edges: open_edges.clone(), walls: wall_lines.clone() }
660            };
661            feats.push(Feature { id: String::new(), solid: solid.clone(), kind, faces: faces.clone(), direction: Point3::Z, machinable: true, reason: None });
662        } else if islands == 0 && shells.len() == 1 {
663            if let Some((centre, width)) = stadium_of(&shells[0].outer, tol) {
664                feats.push(Feature { id: String::new(), solid: solid.clone(), kind: FeatureKind::Slot { centre, width, floor_z, top_z: top }, faces: faces.clone(), direction: Point3::Z, machinable: true, reason: None });
665            } else {
666                feats.push(Feature { id: String::new(), solid: solid.clone(), kind: FeatureKind::Pocket { floor: r.region.clone(), floor_z, top_z: top, open: false, wall_chains: chains, corner_radius, islands, open_edges: Vec::new(), walls: wall_lines.clone() }, faces: faces.clone(), direction: Point3::Z, machinable: true, reason: None });
667            }
668        } else {
669            feats.push(Feature { id: String::new(), solid: solid.clone(), kind: FeatureKind::Pocket { floor: r.region.clone(), floor_z, top_z: top, open: false, wall_chains: chains, corner_radius, islands, open_edges: Vec::new(), walls: wall_lines.clone() }, faces: faces.clone(), direction: Point3::Z, machinable: true, reason: None });
670        }
671    }
672    // Islands: bosses standing on this floor.
673    for (li, l) in r.region.loops.iter().enumerate() {
674        if l.is_ccw() {
675            continue;
676        }
677        // A clockwise floor loop can bound an opening down through the floor,
678        // not just material standing above it. Only rising neighbours establish
679        // an island; downward walls must remain available to their own floors.
680        let island_walls: Vec<&Neighbour<'_>> = neighbours.iter().filter(|nb| nb.island == Some(li) && rise(ctx, f, normal, nb.face) > tol.distance_eps).collect();
681        if island_walls.is_empty() {
682            continue;
683        }
684        let top_of_island = island_walls.iter().map(|w| ctx.graph.faces[w.face as usize].z_max).fold(f64::NEG_INFINITY, f64::max);
685        let corner = island_walls.iter().filter_map(|w| if let Class::VerticalCylinder(rr) = ctx.class[w.face as usize] { Some(rr) } else { None }).fold(f64::INFINITY, f64::min);
686        let mut island_faces: Vec<String> = island_walls.iter().map(|w| name_of(mesh, w.face)).collect();
687        for w in &island_walls {
688            assigned[w.face as usize] = true;
689            faces.push(name_of(mesh, w.face));
690        }
691        // The island's top face: an upward plane whose centroid lies inside the island loop.
692        let outline = Region { loops: vec![l.reversed()] };
693        let mut top_z_island = top_of_island;
694        for (fi, s) in ctx.graph.faces.iter().enumerate() {
695            if assigned[fi] || mesh.faces[fi].protected_only {
696                continue;
697            }
698            if let Class::Plane(nn) = ctx.class[fi] {
699                if nn.z > 1.0 - 1e-6 && s.z_max > floor_z + tol.distance_eps && outline.contains(Point2::new(s.centroid.x, s.centroid.y), tol) {
700                    assigned[fi] = true;
701                    island_faces.push(name_of(mesh, fi as u32));
702                    top_z_island = top_z_island.max(s.z_max);
703                }
704            }
705        }
706        let reason = boss_access_refusal(ctx, &outline, top_z_island).or_else(|| boss_wall_refusal(ctx, &island_walls, floor_z));
707        feats.push(Feature {
708            id: String::new(),
709            solid: solid.clone(),
710            kind: FeatureKind::Boss { outline, top_z: top_z_island, base_z: floor_z, corner_radius: corner.is_finite().then_some(corner) },
711            faces: island_faces,
712            direction: Point3::Z,
713            machinable: reason.is_none(),
714            reason,
715        });
716    }
717    Ok(Some(feats))
718}
719
720/// Check the actual contour boundary, not the whole enclosed face: a taller
721/// island inside a plate does not obstruct the plate's outside contour.
722/// Clip overhead triangles at the boss top before projecting them, then use
723/// exact line/arc intersections rather than a fixed set of boundary samples.
724fn boss_access_refusal(ctx: &Ctx<'_>, outline: &Region, top: f64) -> Option<String> {
725    let tol = &ctx.input.tolerance;
726    let level = top + tol.distance_eps;
727    let (lo, hi) = outline.bounds();
728    for triangle in &ctx.input.mesh.triangles {
729        if triangle.max_z() <= level || triangle.bounds.max.x < lo.x || triangle.bounds.min.x > hi.x
730            || triangle.bounds.max.y < lo.y || triangle.bounds.min.y > hi.y {
731            continue;
732        }
733        let vertices = triangle.vertices();
734        let mut clipped = Vec::new();
735        for i in 0..3 {
736            let (a, b) = (vertices[i], vertices[(i + 1) % 3]);
737            if (a.z > level) != (b.z > level) {
738                clipped.push(a.lerp(b, (level - a.z) / (b.z - a.z)));
739            }
740            if b.z > level {
741                clipped.push(b);
742            }
743        }
744        let points: Vec<Point2> = clipped.iter().map(|p| Point2::new(p.x, p.y)).collect();
745        if points.len() < 3 {
746            continue;
747        }
748        let mut projected = Loop::polygon(&points);
749        if projected.signed_area().abs() <= tol.area_eps {
750            continue;
751        }
752        if !projected.is_ccw() { projected = projected.reversed(); }
753        let footprint = Region { loops: vec![projected] };
754        let blocked = outline.loops.iter().flat_map(|l| &l.segments).any(|s| {
755            footprint.contains(s.start, tol) || footprint.contains(s.end, tol)
756                || footprint.loops[0].segments.iter().any(|edge| !s.intersect(edge, tol).is_empty())
757        });
758        if blocked {
759            let face = &ctx.input.mesh.faces[triangle.face as usize];
760            return Some(format!("undercut wall contour is blocked from +Z by {}/{}; needs another setup", face.solid, face.face));
761        }
762    }
763    None
764}
765
766/// A constant-depth boss contour needs vertical walls with a common flat
767/// bottom. A vertical carrier alone does not prove that: its lower trim may
768/// follow a sculpted floor, which a contour at the lowest vertex would gouge.
769fn boss_wall_refusal(ctx: &Ctx<'_>, walls: &[&Neighbour<'_>], base_z: f64) -> Option<String> {
770    let eps = ctx.input.tolerance.distance_eps;
771    for wall in walls {
772        let name = name_of(ctx.input.mesh, wall.face);
773        match ctx.class[wall.face as usize] {
774            Class::Plane(n) if n.z.abs() <= 1e-6 => {}
775            Class::VerticalCylinder(_) => {}
776            _ => return Some(format!("wall {name} is drafted or curved; needs 3D finishing or another setup")),
777        }
778        type Key = (u64, u64, u64);
779        let key = |p: Point3| -> Key { (crate::geom::canonical_bits(p.x), crate::geom::canonical_bits(p.y), crate::geom::canonical_bits(p.z)) };
780        let mut edges: BTreeMap<(Key, Key), (Point3, Point3, u32)> = BTreeMap::new();
781        for triangle in ctx.input.mesh.triangles.iter().filter(|t| t.face == wall.face) {
782            let v = triangle.vertices();
783            for i in 0..3 {
784                let (a, b) = (v[i], v[(i + 1) % 3]);
785                let (ka, kb) = (key(a), key(b));
786                let entry = edges.entry((ka.min(kb), ka.max(kb))).or_insert((a, b, 0));
787                entry.2 += 1;
788            }
789        }
790        let top = ctx.graph.faces[wall.face as usize].z_max;
791        let mut bottom_found = false;
792        for (a, b, count) in edges.values() {
793            if *count != 1 || a.distance_xy(*b) <= eps {
794                continue; // internal triangulation or vertical side seam
795            }
796            if (a.z - top).abs() <= eps && (b.z - top).abs() <= eps {
797                continue;
798            }
799            if (a.z - base_z).abs() > eps || (b.z - base_z).abs() > eps {
800                return Some(format!("wall {name} has a varying bottom; needs 3D finishing"));
801            }
802            bottom_found = true;
803        }
804        if !bottom_found {
805            return Some(format!("wall {name} has no certified flat bottom; needs 3D finishing"));
806        }
807    }
808    None
809}
810
811/// Connected runs of walls (walls adjacent to each other).
812fn wall_chains(ctx: &Ctx<'_>, walls: &[&Neighbour<'_>]) -> u32 {
813    let n = walls.len();
814    let mut parent: Vec<usize> = (0..n).collect();
815    fn find(p: &mut [usize], mut i: usize) -> usize {
816        while p[i] != i {
817            p[i] = p[p[i]];
818            i = p[i];
819        }
820        i
821    }
822    for i in 0..n {
823        for j in i + 1..n {
824            if ctx.graph.adjacency(walls[i].face, walls[j].face).is_some() {
825                let (a, b) = (find(&mut parent, i), find(&mut parent, j));
826                if a != b {
827                    parent[a.max(b)] = a.min(b);
828                }
829            }
830        }
831    }
832    (0..n).filter(|&i| find(&mut parent, i) == i).count() as u32
833}
834
835/// A stadium outline: two parallel lines joined by two half circles of the
836/// same radius. Returns the centre line and the width.
837fn stadium_of(outer: &Loop, tol: &Tolerance) -> Option<(Chain, f64)> {
838    let l = simplified(outer, tol);
839    if l.segments.len() != 4 {
840        return None;
841    }
842    let arcs: Vec<&Segment> = l.segments.iter().filter(|s| s.is_arc()).collect();
843    let lines: Vec<&Segment> = l.segments.iter().filter(|s| !s.is_arc()).collect();
844    if arcs.len() != 2 || lines.len() != 2 {
845        return None;
846    }
847    let r = arcs[0].radius();
848    if (arcs[1].radius() - r).abs() > tol.distance_eps {
849        return None;
850    }
851    for a in &arcs {
852        if (a.angles().1.abs() - std::f64::consts::PI).abs() > 1e-6 {
853            return None;
854        }
855    }
856    let c0 = arcs[0].arc.unwrap().center;
857    let c1 = arcs[1].arc.unwrap().center;
858    if c0.distance(c1) <= tol.point_eps {
859        return None;
860    }
861    Some((Chain::open(vec![Segment::line(c0, c1)]), 2.0 * r))
862}
863
864/// The shared edge of two faces as a planar chain at the upper face's height.
865fn shared_chain(adj: &Adjacency, tol: &Tolerance) -> Option<Chain> {
866    let segs: Vec<Segment> = adj
867        .segments
868        .iter()
869        .filter_map(|(a, b)| {
870            let s = Segment::line(Point2::new(a.x, a.y), Point2::new(b.x, b.y));
871            (s.start.distance(s.end) > tol.point_eps).then_some(s)
872        })
873        .collect();
874    let mut chains = chain_undirected(segs, tol);
875    chains.sort_by(|a, b| b.length().total_cmp(&a.length()));
876    let mut c = chains.into_iter().next()?;
877    c.segments = simplified(&Loop::new(c.segments.clone()), tol).segments;
878    Some(c)
879}
880
881fn upper_neighbour<'a>(ctx: &'a Ctx<'_>, f: u32) -> Option<(u32, &'a Adjacency)> {
882    ctx.graph
883        .neighbours(f)
884        .into_iter()
885        .filter(|(nf, adj)| adj.convex_length > adj.concave_length && matches!(ctx.class[*nf as usize], Class::Plane(nn) if nn.z > 1.0 - 1e-6))
886        .max_by(|a, b| a.1.length().total_cmp(&b.1.length()))
887}
888
889fn chamfer_feature(ctx: &Ctx<'_>, f: u32, normal: Point3) -> Result<Feature, &'static str> {
890    let tol = &ctx.input.tolerance;
891    let solid = ctx.input.mesh.faces[f as usize].solid.clone();
892    let (upper, adj) = upper_neighbour(ctx, f).ok_or(NO_UPPER)?;
893    let stats = &ctx.graph.faces[f as usize];
894    let upper_chain = shared_chain(adj, tol).ok_or(NO_CHAIN)?;
895    // The lower edge (with the steeper face below) is the wall line the
896    // chamfer replaces; its direction keeps the part interior (the upper
897    // edge's side) on the left.
898    let lower = ctx
899        .graph
900        .neighbours(f)
901        .into_iter()
902        .filter(|(nf, a)| *nf != upper && a.convex_length > a.concave_length && ctx.graph.faces[*nf as usize].z_min < stats.z_min + tol.distance_eps)
903        .max_by(|a, b| a.1.length().total_cmp(&b.1.length()));
904    let mut chain = match lower {
905        Some((_, a)) => shared_chain(a, tol).ok_or("the edge with the face below is not one connected chain")?,
906        None => upper_chain.clone(),
907    };
908    let interior = upper_chain.segments[0].midpoint().sub(chain.segments[0].nearest(upper_chain.segments[0].midpoint()).0);
909    if !chain.closed && chain.segments[0].tangent_at(0.5).cross(interior) < 0.0 {
910        chain = chain.reversed();
911    }
912    let horizontal = normal.x.hypot(normal.y);
913    let width = (stats.z_max - stats.z_min) * normal.z / horizontal;
914    Ok(Feature {
915        id: String::new(),
916        solid,
917        kind: FeatureKind::Chamfer { chain, width, z: ctx.graph.faces[upper as usize].z_max, length: adj.length() },
918        faces: vec![name_of(ctx.input.mesh, f)],
919        direction: Point3::Z,
920        machinable: true,
921        reason: None,
922    })
923}
924
925const NO_UPPER: &str = "no upward horizontal face above it across a convex edge";
926const NO_CHAIN: &str = "the edge with the face above is not one connected chain";
927
928fn fillet_feature(ctx: &Ctx<'_>, f: u32, radius: f64) -> Result<Feature, &'static str> {
929    let tol = &ctx.input.tolerance;
930    let solid = ctx.input.mesh.faces[f as usize].solid.clone();
931    let (upper, adj) = upper_neighbour(ctx, f).ok_or(NO_UPPER)?;
932    let chain = shared_chain(adj, tol).ok_or(NO_CHAIN)?;
933    Ok(Feature {
934        id: String::new(),
935        solid,
936        kind: FeatureKind::Fillet { chain, radius, z: ctx.graph.faces[upper as usize].z_max, length: adj.length() },
937        faces: vec![name_of(ctx.input.mesh, f)],
938        direction: Point3::Z,
939        machinable: true,
940        reason: None,
941    })
942}
943
944/// A flat bottom gives an exact outer loop. All body vertices must project
945/// inside it; an upper overhang cannot be silently classified as vertical.
946fn outer_profiles(ctx:&Ctx<'_>)->Result<Vec<Feature>,CamError> {
947    let mesh=ctx.input.mesh;let tol=&ctx.input.tolerance;let mut out=Vec::new();
948    let solids:std::collections::BTreeSet<_>=mesh.faces.iter().filter(|f|!f.protected_only).map(|f|f.solid.clone()).collect();
949    for solid in solids {
950        let ids:Vec<usize>=mesh.faces.iter().enumerate().filter(|(_,f)|f.solid==solid && !f.protected_only).map(|(i,_)|i).collect();
951        let bottom=ids.iter().map(|&i|ctx.graph.faces[i].z_min).fold(f64::INFINITY,f64::min);
952        let top=ids.iter().map(|&i|ctx.graph.faces[i].z_max).fold(f64::NEG_INFINITY,f64::max);
953        if !bottom.is_finite() || !top.is_finite() || top-bottom<=tol.distance_eps {continue;}
954        let bottoms:Vec<_>=ids.iter().copied().filter(|&i|matches!(ctx.class[i],Class::Plane(n) if n.z< -1.+1e-6) && (ctx.graph.faces[i].z_max-bottom).abs()<=tol.distance_eps).collect();
955        let mut outline=Region::empty();
956        for &i in &bottoms {
957            let region=if let Some(r)=ctx.region[i].filter(|_|mesh.faces.iter().filter(|f|f.face==mesh.faces[i].face).count()==1) {r.region.clone()}else{crate::planar::extract::face_outline(mesh,|f|f as usize==i,tol)?.0};
958            let outer=Region::new(region.loops.into_iter().filter(|l|l.is_ccw()).collect(),tol)?;
959            outline=outline.union(&outer,tol)?;
960        }
961        let mut walls=ProfileWalls::Vertical;let mut faces=Vec::new();
962        if outline.loops.is_empty() {
963            let projected=crate::planar::extract::silhouette(mesh,|f|ids.contains(&(f as usize)),tol)?;
964            outline=Region::new(projected.loops.into_iter().filter(|l|l.is_ccw()).collect(),tol)?;
965            walls=ProfileWalls::Uncertified;
966        } else {
967            for (&(a,b),adj) in &ctx.graph.adjacency {
968                let side=if bottoms.contains(&(a as usize)){b as usize}else if bottoms.contains(&(b as usize)){a as usize}else{continue};
969                let on_outer=adj.segments.iter().any(|(p,_)|outline.loops.iter().any(|l|l.segments.iter().any(|e|e.distance(Point2::new(p.x,p.y))<=tol.distance_eps*10.)));
970                if !on_outer {continue;}
971                faces.push(mesh.faces[side].face.clone());
972                let class=match ctx.class[side] {
973                    Class::Plane(n) if n.z.abs()<1e-6=>ProfileWalls::Vertical,
974                    Class::VerticalCylinder(_)=>ProfileWalls::Vertical,
975                    Class::Plane(n) if n.z<0.=>ProfileWalls::Undercut,
976                    Class::Plane(_)=>ProfileWalls::Drafted,
977                    _=>match ctx.carrier[side] {Some(Carrier::Cone{radius0,radius1,..}) if radius1>radius0=>ProfileWalls::Undercut,Some(Carrier::Cone{..})=>ProfileWalls::Drafted,_=>ProfileWalls::Uncertified},
978                };
979                if class==ProfileWalls::Undercut || walls==ProfileWalls::Vertical {walls=class;}
980            }
981            if faces.is_empty(){walls=ProfileWalls::Uncertified;}
982            if mesh.triangles.iter().filter(|t|ids.contains(&(t.face as usize))).flat_map(|t|t.vertices()).any(|p|outline.signed_distance(Point2::new(p.x,p.y),tol)< -tol.distance_eps*10.) {
983                walls=ProfileWalls::Undercut;
984                let projected=crate::planar::extract::silhouette(mesh,|f|ids.contains(&(f as usize)),tol)?;
985                outline=Region::new(projected.loops.into_iter().filter(|l|l.is_ccw()).collect(),tol)?;
986            }
987        }
988        faces.extend(bottoms.iter().map(|&i|mesh.faces[i].face.clone()));faces.sort();faces.dedup();
989        let reason=match walls {ProfileWalls::Vertical=>None,ProfileWalls::Drafted=>Some("outer profile has drafted walls; requires 3D finishing".into()),ProfileWalls::Undercut=>Some("outer profile has undercut or overhanging walls; requires another setup or 3D finishing".into()),ProfileWalls::Uncertified=>Some("outer profile wall/depth classification is uncertified; requires a flat bottom and analytic side walls".into())};
990        out.push(Feature{id:String::new(),solid,kind:FeatureKind::OuterProfile{outline,top_z:top,bottom_z:bottom,walls},faces,direction:Point3::Z,machinable:reason.is_none(),reason});
991    }
992    Ok(out)
993}