1use 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
17pub 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 OuterProfile { outline: Region, top_z: f64, bottom_z: f64, walls: ProfileWalls },
33 Face { region: Region, z: f64 },
35 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 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 Slot { centre: Chain, width: f64, floor_z: f64, top_z: f64 },
66 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 Chamfer { chain: Chain, width: f64, z: f64, length: f64 },
79 Fillet { chain: Chain, radius: f64, z: f64, length: f64 },
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all="camelCase")]
86pub enum ProfileWalls { Vertical, Drafted, Undercut, Uncertified }
87
88#[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 pub solid: String,
120 pub kind: FeatureKind,
121 pub faces: Vec<String>,
124 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 pub unassigned: Vec<(String, String)>,
137 #[serde(default)]
140 pub accounting: Option<Vec<FaceAccount>>,
141}
142
143#[derive(Default)]
145struct Notes {
146 refusals: BTreeMap<u32, Vec<Refusal>>,
147 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#[derive(Clone, Copy, Debug, PartialEq)]
159enum Class {
160 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_of: Vec<Option<usize>>,
175}
176
177fn name_of(mesh: &MeshSnapshot, f: u32) -> String {
178 mesh.faces[f as usize].face.clone()
179}
180
181pub 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 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 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 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
311fn 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
323fn 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
332fn 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 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 ¬es.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 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
415fn 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
435fn 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 island: Option<usize>,
453}
454
455pub(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
479pub(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 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(®ion.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 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 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 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 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 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 for (li, l) in r.region.loops.iter().enumerate() {
674 if l.is_ccw() {
675 continue;
676 }
677 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 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
720fn 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
766fn 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; }
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
811fn 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
835fn 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
864fn 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 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
944fn 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}