forked from 1b-agent/fieldcraft-upstream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
418 lines
20 KiB
Rust
418 lines
20 KiB
Rust
//! Defeaturing for meshing: holes, fillets, chamfers and small bosses taken out of a design so they
|
||
//! do not drive the mesh.
|
||
//!
|
||
//! A design opened from its timeline knows its features ([`Design::features`]), so a hole is
|
||
//! removed by rebuilding the design with the hole's feature suppressed, exactly as SolidCraft
|
||
//! would build it without it: the hole is filled, a fillet or chamfer goes back to its sharp edge,
|
||
//! a boss is gone. The faces the other features made keep their persistent names, so sets, seeds
|
||
//! and loads on them still resolve.
|
||
//!
|
||
//! A feature that cannot be suppressed on its own (a later feature needs it: a fillet along the
|
||
//! hole's edge, a pattern of it), or whose suppression would take a body away, is kept in the
|
||
//! B-rep and defeatured for meshing only instead, as virtual topology
|
||
//! ([`crate::virtual_faces`]): its faces are merged into the neighbour they share the longest
|
||
//! edges with, so the meshers see no edges around it (the material stays). Such a feature is
|
||
//! listed with `virtual` in [`Design::defeat_report`].
|
||
//!
|
||
//! Defeatures live in the FieldCraft model by feature id (the `F<id>` of persistent names, stable
|
||
//! across edits of the design) and are applied whenever the design is opened, before the
|
||
//! partitions. [`Design::defeatured`] keeps the design as opened, so switching a feature back on
|
||
//! rebuilds from it.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::sync::Arc;
|
||
|
||
use serde::Serialize;
|
||
use solidcraft_doc::FeatureKind;
|
||
|
||
use crate::features::FeatureNode;
|
||
use crate::virtual_faces::Merge;
|
||
use crate::{Design, Error, FeatureError, Result};
|
||
|
||
/// Most features all of a design's defeatures name together.
|
||
pub const MAX_DEFEATURED: usize = 1024;
|
||
/// Most rebuilds spent finding which features suppress on their own when suppressing them all
|
||
/// at once breaks the design; the features past it are defeatured virtually.
|
||
const MAX_TRIALS: usize = 64;
|
||
|
||
/// Features of the design's timeline suppressed for meshing (`geometry.defeature`).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Defeature {
|
||
pub name: String,
|
||
/// Feature ids (`F<id>`).
|
||
pub features: Vec<u64>,
|
||
}
|
||
|
||
/// How a defeatured feature was taken out.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||
pub struct Defeated {
|
||
/// The defeature that names it.
|
||
pub defeature: String,
|
||
pub feature: u64,
|
||
/// `suppressed` (the design rebuilt without it) or `virtual` (its faces merged into a
|
||
/// neighbour for meshing; the B-rep keeps it).
|
||
pub method: &'static str,
|
||
/// Why it could not be suppressed, for a virtual one.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub reason: Option<String>,
|
||
}
|
||
|
||
/// The defeatures applied to a design, and the design as opened.
|
||
#[derive(Debug)]
|
||
pub(crate) struct Applied {
|
||
/// The design as opened, before any defeature.
|
||
original: Design,
|
||
defeatures: Vec<Defeature>,
|
||
report: Vec<Defeated>,
|
||
/// The virtual defeatures, as merges applied with the model's own.
|
||
merges: Vec<Merge>,
|
||
}
|
||
|
||
/// A feature a mesh-prep pass may take out, and its size.
|
||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||
pub struct Candidate {
|
||
pub id: u64,
|
||
pub name: String,
|
||
/// The feature's definition type (`hole`, `extrude`, `fillet`, …).
|
||
#[serde(rename = "type")]
|
||
pub kind: String,
|
||
/// `hole`, `fillet`, `chamfer` or `boss`.
|
||
pub category: &'static str,
|
||
/// The size compared: a hole's diameter, a fillet's radius, a chamfer's distance, a boss's
|
||
/// height (mm).
|
||
pub size: f64,
|
||
/// The body's faces it made.
|
||
pub faces: Vec<String>,
|
||
}
|
||
|
||
/// Whether the feature `node` is a hole, fillet, chamfer or boss, and its size.
|
||
pub fn candidate(node: &FeatureNode) -> Option<Candidate> {
|
||
let v = |k: &str| node.values.get(k).copied();
|
||
let op = node.operation.as_deref();
|
||
// A circular profile's diameter (an extrude of a circle).
|
||
let circle = || {
|
||
let p = node.sketch.as_ref()?.profiles.first()?;
|
||
match p.outer.as_slice() {
|
||
[crate::Curve::Circle { radius, .. }] if p.holes.is_empty() => Some(2.0 * radius),
|
||
_ => None,
|
||
}
|
||
};
|
||
let (category, size) = match (node.kind.as_str(), op) {
|
||
("hole", _) => ("hole", v("diameter")?),
|
||
("fillet", _) => ("fillet", v("radius")?),
|
||
("chamfer", _) => ("chamfer", v("distance")?),
|
||
("cylinder", Some("cut")) => ("hole", 2.0 * v("radius")?),
|
||
("extrude", Some("cut")) => ("hole", circle()?),
|
||
("extrude", Some("join")) => ("boss", v("distance")?.abs()),
|
||
("cylinder" | "box", Some("join")) => ("boss", v("height")?.abs()),
|
||
_ => return None,
|
||
};
|
||
(size.is_finite() && size > 0.0).then(|| Candidate {
|
||
id: node.id,
|
||
name: node.name.clone(),
|
||
kind: node.kind.clone(),
|
||
category,
|
||
size,
|
||
faces: node.faces.clone(),
|
||
})
|
||
}
|
||
|
||
impl Design {
|
||
/// The design as opened, before its defeatures.
|
||
pub fn undefeatured(&self) -> &Design {
|
||
self.defeat.as_deref().map_or(self, |a| &a.original)
|
||
}
|
||
|
||
/// How each defeatured feature was taken out, in the defeatures' order.
|
||
pub fn defeat_report(&self) -> &[Defeated] {
|
||
self.defeat.as_deref().map_or(&[], |a| a.report.as_slice())
|
||
}
|
||
|
||
/// The design with `defeatures` applied, from the design as opened, and the merges that
|
||
/// defeature its features virtually (apply them with the model's own, [`Design::merged`]).
|
||
/// A defeature naming a feature the timeline lacks, or one that builds a body or a sketch, is
|
||
/// listed in [`Design::errors`] (`defeature <name>`) and the rest still apply. A design
|
||
/// without a timeline defeatures nothing (each is an error).
|
||
pub fn defeatured(&self, defeatures: &[Defeature]) -> Result<(Design, Vec<Merge>)> {
|
||
let base = self.undefeatured();
|
||
if defeatures.iter().all(|d| d.features.is_empty()) {
|
||
return Ok((base.clone(), Vec::new()));
|
||
}
|
||
if let Some(a) = self.defeat.as_deref().filter(|a| a.defeatures == defeatures) {
|
||
return Ok((self.clone(), a.merges.clone()));
|
||
}
|
||
if defeatures.iter().map(|d| d.features.len()).sum::<usize>() > MAX_DEFEATURED {
|
||
return Err(Error::Invalid(format!("a design takes at most {MAX_DEFEATURED} defeatured features")));
|
||
}
|
||
let mut errors = Vec::new();
|
||
let fail = |d: &Defeature, message: String| FeatureError { feature: format!("defeature {}", d.name), message, bodies: Vec::new() };
|
||
// The features to take out, each with the first defeature naming it.
|
||
let mut wanted: BTreeMap<u64, &str> = BTreeMap::new();
|
||
let mut order = Vec::new();
|
||
for d in defeatures {
|
||
for &id in &d.features {
|
||
let refused = match base.history.as_deref().map(|h| h.doc.feature(id)) {
|
||
None => Some("the design has no feature timeline".to_string()),
|
||
Some(None) => Some(format!("the design has no feature F{id}")),
|
||
Some(Some(f)) if !suppressible(&f.kind) => {
|
||
Some(format!("F{id} ({}) builds a body or a sketch; it is not a feature to defeature", f.name))
|
||
}
|
||
Some(Some(_)) => None,
|
||
};
|
||
match refused {
|
||
Some(m) => errors.push(fail(d, m)),
|
||
None if !wanted.contains_key(&id) => {
|
||
wanted.insert(id, &d.name);
|
||
order.push(id);
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
}
|
||
// Suppress them all at once; when that breaks the design, one at a time, keeping those
|
||
// that rebuild cleanly.
|
||
let mut kept: BTreeSet<u64> = order.iter().copied().collect();
|
||
let mut rebuilt = rebuild_without(base, &kept)?.ok();
|
||
let mut reasons: BTreeMap<u64, String> = BTreeMap::new();
|
||
if rebuilt.is_none() {
|
||
kept.clear();
|
||
for (k, &id) in order.iter().enumerate() {
|
||
if k >= MAX_TRIALS {
|
||
reasons.insert(id, format!("more than {MAX_TRIALS} features to try one at a time"));
|
||
continue;
|
||
}
|
||
let mut next = kept.clone();
|
||
next.insert(id);
|
||
match rebuild_without(base, &next)? {
|
||
Ok(d) => {
|
||
kept = next;
|
||
rebuilt = Some(d);
|
||
}
|
||
Err(why) => {
|
||
reasons.insert(id, why);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut design = match rebuilt {
|
||
Some(d) if !kept.is_empty() => d,
|
||
_ => base.clone(),
|
||
};
|
||
// The rest: their faces merged into a neighbour, body by body.
|
||
let mut merges = Vec::new();
|
||
let mut report = Vec::new();
|
||
let graph = if reasons.is_empty() { Vec::new() } else { base.features(None)? };
|
||
for &id in &order {
|
||
let defeature = wanted.get(&id).map_or_else(String::new, |n| n.to_string());
|
||
if kept.contains(&id) {
|
||
report.push(Defeated { defeature, feature: id, method: "suppressed", reason: None });
|
||
continue;
|
||
}
|
||
for b in &graph {
|
||
let Some(node) = b.features.iter().find(|f| f.id == id).filter(|f| !f.faces.is_empty()) else { continue };
|
||
match absorb(base, &b.body, &node.faces) {
|
||
Ok(Some(into)) => {
|
||
let mut faces = node.faces.clone();
|
||
faces.push(into.clone());
|
||
merges.push(Merge { name: format!("{defeature}-F{id}"), body: b.body.clone(), faces, into: Some(into) });
|
||
}
|
||
Ok(None) => {}
|
||
Err(e) => errors.push(FeatureError {
|
||
feature: format!("defeature {defeature}"),
|
||
message: format!("F{id} on `{}`: {e}", b.body),
|
||
bodies: vec![b.body.clone()],
|
||
}),
|
||
}
|
||
}
|
||
report.push(Defeated { defeature, feature: id, method: "virtual", reason: reasons.get(&id).cloned() });
|
||
}
|
||
design.errors.extend(errors);
|
||
design.defeat = Some(Arc::new(Applied { original: base.clone(), defeatures: defeatures.to_vec(), report, merges: merges.clone() }));
|
||
Ok((design, merges))
|
||
}
|
||
}
|
||
|
||
/// Whether a feature of this kind may be taken out without taking a body or a sketch with it.
|
||
fn suppressible(kind: &FeatureKind) -> bool {
|
||
use solidcraft_doc::Operation::NewBody;
|
||
match kind {
|
||
FeatureKind::Sketch { .. }
|
||
| FeatureKind::ConstructionPlane { .. }
|
||
| FeatureKind::ConstructionAxis { .. }
|
||
| FeatureKind::ConstructionPoint { .. }
|
||
| FeatureKind::Combine { .. }
|
||
| FeatureKind::Split { .. } => false,
|
||
FeatureKind::Extrude { operation, .. }
|
||
| FeatureKind::Revolve { operation, .. }
|
||
| FeatureKind::Sweep { operation, .. }
|
||
| FeatureKind::Loft { operation, .. }
|
||
| FeatureKind::Box { operation, .. }
|
||
| FeatureKind::Cylinder { operation, .. }
|
||
| FeatureKind::Sphere { operation, .. }
|
||
| FeatureKind::Torus { operation, .. } => *operation != NewBody,
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// `base` rebuilt with `ids` suppressed: `Err(why)` when that fails a feature that built in
|
||
/// `base` or loses one of its bodies.
|
||
fn rebuild_without(base: &Design, ids: &BTreeSet<u64>) -> Result<std::result::Result<Design, String>> {
|
||
let history = base.history.as_deref().ok_or_else(|| Error::Invalid("the design has no feature timeline".into()))?;
|
||
let mut doc = history.doc.clone();
|
||
for &id in ids {
|
||
if let Some(f) = doc.feature_mut(id) {
|
||
f.suppressed = true;
|
||
}
|
||
}
|
||
let design = crate::design::rebuild(&doc)?;
|
||
let failed: Vec<String> = design
|
||
.errors
|
||
.iter()
|
||
.filter(|e| !base.errors.iter().any(|b| b.feature == e.feature))
|
||
.map(|e| format!("{} no longer builds: {}", e.feature, e.message))
|
||
.collect();
|
||
if !failed.is_empty() {
|
||
return Ok(Err(failed.join("; ")));
|
||
}
|
||
let lost: Vec<&str> = base.source.iter().map(|b| b.name.as_str()).filter(|n| design.source.iter().all(|b| b.name != *n)).collect();
|
||
if !lost.is_empty() {
|
||
return Ok(Err(format!("body {} would be gone", lost.join(", "))));
|
||
}
|
||
Ok(Ok(design))
|
||
}
|
||
|
||
/// The face of `body` (in `base` as opened) outside `faces` that `faces` share the longest edges
|
||
/// with; `None` when the body has none of them.
|
||
fn absorb(base: &Design, body: &str, faces: &[String]) -> Result<Option<String>> {
|
||
let Some(shape) = base.source_body(body) else { return Ok(None) };
|
||
for f in faces {
|
||
if shape.faces_named(f).is_empty() {
|
||
continue;
|
||
}
|
||
// A face of the feature may border only the feature's own faces (the middle of a
|
||
// fillet chain): the next one finds the outside.
|
||
let mut probe = shape.clone();
|
||
let others: Vec<String> = faces.iter().filter(|g| *g != f && !shape.faces_named(g).is_empty()).cloned().collect();
|
||
if !others.is_empty() {
|
||
let mut all = vec![f.clone()];
|
||
all.extend(others);
|
||
match shape.merge_faces(f, &all) {
|
||
Ok(m) => probe = m,
|
||
Err(_) => continue,
|
||
}
|
||
}
|
||
if let Ok(n) = probe.absorbing_neighbour(f) {
|
||
return Ok(Some(n));
|
||
}
|
||
}
|
||
Err(Error::Invalid("its faces have no neighbour to merge into".into()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::sample::{Plate, plate_design};
|
||
|
||
fn plate() -> Design {
|
||
crate::open_design(&plate_design(&Plate::default()).unwrap()).unwrap()
|
||
}
|
||
|
||
fn hole(d: &Design) -> u64 {
|
||
d.features(None).unwrap()[0].features.iter().filter_map(candidate).find(|c| c.category == "hole").unwrap().id
|
||
}
|
||
|
||
/// Suppressing the plate's hole fills it (the body is a solid block, its faces keep their
|
||
/// names); an empty list brings it back.
|
||
#[test]
|
||
fn the_plate_without_its_hole_is_a_block() {
|
||
let d = plate();
|
||
let id = hole(&d);
|
||
let c = d.features(None).unwrap()[0].features.iter().filter_map(candidate).next().unwrap();
|
||
assert_eq!((c.category, c.size), ("hole", Plate::default().hole_diameter));
|
||
let def = [Defeature { name: "Holes".into(), features: vec![id] }];
|
||
let (filled, merges) = d.defeatured(&def).unwrap();
|
||
assert!(merges.is_empty());
|
||
let p = Plate::default();
|
||
let v = filled.bodies[0].measure().unwrap().volume;
|
||
assert!((v - p.width * p.depth * p.thickness).abs() < 1e-6 * v, "{v}");
|
||
assert_eq!(filled.bodies[0].faces().len(), 6);
|
||
assert!(filled.bodies[0].faces().iter().all(|f| d.bodies[0].faces().iter().any(|g| g.name == f.name)));
|
||
assert_eq!(filled.defeat_report(), [Defeated { defeature: "Holes".into(), feature: id, method: "suppressed", reason: None }]);
|
||
// The feature graph of the defeatured design leaves the hole out; the design as opened keeps it.
|
||
assert_eq!(filled.features(None).unwrap()[0].features.len(), 1);
|
||
assert_eq!(filled.undefeatured().features(None).unwrap()[0].features.len(), 2);
|
||
// Applied again with the same list: the same design, no rebuild; with none: as opened.
|
||
let (again, _) = filled.defeatured(&def).unwrap();
|
||
assert_eq!(again.bodies[0].faces().len(), 6);
|
||
let (back, _) = filled.defeatured(&[]).unwrap();
|
||
assert_eq!(back.bodies[0].faces().len(), d.bodies[0].faces().len());
|
||
assert!(back.defeat_report().is_empty());
|
||
}
|
||
|
||
/// A feature the timeline lacks, or the base extrude, is an error of the defeature, not of
|
||
/// the call.
|
||
#[test]
|
||
fn unknown_and_body_features_are_listed_errors() {
|
||
let d = plate();
|
||
let base = d.features(None).unwrap()[0].features[0].id;
|
||
let (out, _) = d.defeatured(&[Defeature { name: "Bad".into(), features: vec![base, 999] }]).unwrap();
|
||
let errors: Vec<&str> = out.errors.iter().filter(|e| e.feature == "defeature Bad").map(|e| e.message.as_str()).collect();
|
||
assert_eq!(errors.len(), 2, "{errors:?}");
|
||
assert!(errors[0].contains("builds a body") && errors[1].contains("no feature F999"), "{errors:?}");
|
||
assert_eq!(out.bodies[0].faces().len(), d.bodies[0].faces().len());
|
||
}
|
||
|
||
/// The bracket's fillet is suppressed (back to a sharp corner) and listed as one.
|
||
#[cfg(feature = "scripts")]
|
||
#[test]
|
||
fn a_bracket_fillet_goes_back_to_its_sharp_edge() {
|
||
let script = r#"[
|
||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}},
|
||
{"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}},
|
||
{"command": "sketch.finish", "params": {}},
|
||
{"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}},
|
||
{"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}},
|
||
{"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}}
|
||
]"#;
|
||
let d = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
|
||
let cands: Vec<Candidate> = d.features(None).unwrap()[0].features.iter().filter_map(candidate).collect();
|
||
assert_eq!(cands.iter().map(|c| (c.category, c.size)).collect::<Vec<_>>(), [("fillet", 6.0), ("hole", 8.0)]);
|
||
let fillet = cands[0].id;
|
||
let (sharp, merges) = d.defeatured(&[Defeature { name: "Small".into(), features: vec![fillet] }]).unwrap();
|
||
assert!(merges.is_empty());
|
||
let (before, after) = (d.bodies[0].measure().unwrap().volume, sharp.bodies[0].measure().unwrap().volume);
|
||
// The fillet's material comes out: (1 − π/4) r² × 40 in the inside corner.
|
||
let fill = (1.0 - std::f64::consts::FRAC_PI_4) * 36.0 * 40.0;
|
||
assert!((before - after - fill).abs() < 1e-3 * fill, "{before} {after}");
|
||
assert!(sharp.bodies[0].faces().iter().all(|f| !f.name.starts_with(&format!("F{fillet}:"))));
|
||
}
|
||
|
||
/// A hole whose rim a later fillet rounds cannot be suppressed alone (the fillet would lose
|
||
/// its edge): it is defeatured virtually, its faces merged into a neighbour, the B-rep kept.
|
||
#[cfg(feature = "scripts")]
|
||
#[test]
|
||
fn a_hole_a_fillet_needs_is_defeatured_virtually() {
|
||
let script = r#"[
|
||
{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 10, "body_name": "Block"}},
|
||
{"command": "solid.hole", "params": {"position": [20, 20, 10], "diameter": 10}},
|
||
{"command": "solid.fillet", "params": {"edges": [[25, 20, 10]], "radius": 1}}
|
||
]"#;
|
||
let d = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
|
||
assert!(d.errors.is_empty(), "{:?}", d.errors);
|
||
let graph = d.features(None).unwrap();
|
||
let hole = graph[0].features.iter().find(|f| f.kind == "hole").unwrap();
|
||
let (out, merges) = d.defeatured(&[Defeature { name: "H".into(), features: vec![hole.id] }]).unwrap();
|
||
let report = out.defeat_report();
|
||
assert_eq!((report.len(), report[0].method), (1, "virtual"), "{report:?}");
|
||
assert!(report[0].reason.as_deref().is_some_and(|r| r.contains("no longer builds")), "{report:?}");
|
||
assert_eq!(merges.len(), 1, "{merges:?}");
|
||
let m = &merges[0];
|
||
assert!(m.into.as_ref().is_some_and(|i| !hole.faces.contains(i)) && m.faces.len() == hole.faces.len() + 1, "{m:?}");
|
||
// The B-rep is the design as opened; the merge applies.
|
||
assert_eq!(out.bodies[0].faces().len(), d.bodies[0].faces().len());
|
||
let merged = out.merged(&merges).unwrap();
|
||
assert!(merged.errors.is_empty(), "{:?}", merged.errors);
|
||
assert_eq!(merged.bodies[0].faces().len(), d.bodies[0].faces().len() - hole.faces.len());
|
||
}
|
||
}
|