forked from 1b-agent/fieldcraft-upstream
geometry: the replayed feature graph per body (#402); a partition may list a cut body for its cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
d3ac6395bb
commit
4cfb6dd3de
7 files changed
+636
-23
No files matched your search
Generated
+1
@@ -1356,6 +1356,7 @@ version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-engine",
|
||||
|
||||
@@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec {
|
||||
pub const PARTITION: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition",
|
||||
label: "Partition",
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides; a body earlier partitions cut stands for its cells)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
|
||||
form: Some(super::forms::PARTITION),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design, Requirement::DatumPlane],
|
||||
@@ -478,7 +478,7 @@ pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
/// Cut the session's design by its model's partitions again. The bodies that are gone (cells
|
||||
/// whose partition went, a body a new partition cut) take their meshes with them (#385): what
|
||||
/// went is in the returned [`super::replace::Cleared`], which the command reports.
|
||||
fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
|
||||
pub(super) fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
|
||||
let Some(d) = &s.design else { return Ok(super::replace::Cleared::default()) };
|
||||
let cut = partitioned(d, &s.model)?;
|
||||
let gone: Vec<String> = d.bodies.iter().filter(|b| cut.body(&b.name).is_none()).map(|b| b.name.clone()).collect();
|
||||
@@ -508,7 +508,7 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (faces, bodies): (usize, Vec<String>) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect());
|
||||
let mut warnings: Vec<String> = report.map(|r| r.warnings.clone()).unwrap_or_default();
|
||||
for cell in &cells {
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) {
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape, None).err()) {
|
||||
warnings.push(format!(
|
||||
"partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)"
|
||||
));
|
||||
@@ -534,10 +534,10 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(cleared.report_if_any(s, reply))
|
||||
}
|
||||
|
||||
/// Whether a cell's boundary, tessellated as the meshers take it by default, bounds a volume mesh:
|
||||
/// Whether a cell's boundary, tessellated as the meshers take it (by default, or at `tolerance`), bounds a volume mesh:
|
||||
/// the defects (open edges, edges on three triangles) when it does not.
|
||||
fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> {
|
||||
let input = super::hex::shape_input(shape, None)?;
|
||||
pub(super) fn unmeshable(shape: &fieldcraft_geometry::Shape, tolerance: Option<f64>) -> Result<()> {
|
||||
let input = super::hex::shape_input(shape, tolerance)?;
|
||||
let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() };
|
||||
fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options)
|
||||
.map(|_| ())
|
||||
|
||||
@@ -18,6 +18,7 @@ thiserror = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
solidcraft-engine = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct Design {
|
||||
pub cuts: Vec<crate::partition::CutReport>,
|
||||
/// The bodies as opened, before any partition: what [`Design::partitioned`] starts from.
|
||||
pub(crate) source: std::sync::Arc<Vec<Shape>>,
|
||||
/// The timeline the design was rebuilt from ([`Design::features`]).
|
||||
pub(crate) history: Option<std::sync::Arc<crate::features::History>>,
|
||||
}
|
||||
|
||||
/// A feature of the design that failed to rebuild.
|
||||
@@ -77,6 +79,11 @@ impl Design {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
|
||||
/// The body `name` of the design as opened (before any partition).
|
||||
pub fn source_body(&self, name: &str) -> Option<&Shape> {
|
||||
self.source.iter().find(|b| b.name == name)
|
||||
}
|
||||
|
||||
/// The body of the design as opened that `name` is, or that partitions cut it from
|
||||
/// (`Base` for `Base/a/b1`).
|
||||
pub fn source_of(&self, name: &str) -> Option<&str> {
|
||||
@@ -101,6 +108,11 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
m.evaluate(doc);
|
||||
Ok(m)
|
||||
})?;
|
||||
let history = crate::features::History {
|
||||
doc: doc.clone(),
|
||||
state: model.state(),
|
||||
errors: model.results.iter().filter(|r| !r.skipped).filter_map(|r| Some((r.id, r.error.clone()?))).collect(),
|
||||
};
|
||||
// Placed in the world: a component's bodies through every occurrence of it (an instance is a
|
||||
// body of its own, named after its occurrence path), as SolidCraft displays and exports them.
|
||||
let state = guard("place components", || Ok(solidcraft_doc::world_state(doc, &model.state())))?;
|
||||
@@ -137,13 +149,20 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
Ok(Design { name: doc.name.clone(), source: std::sync::Arc::new(bodies.clone()), bodies, errors, cuts: Vec::new() })
|
||||
Ok(Design {
|
||||
name: doc.name.clone(),
|
||||
source: std::sync::Arc::new(bodies.clone()),
|
||||
bodies,
|
||||
errors,
|
||||
cuts: Vec::new(),
|
||||
history: Some(std::sync::Arc::new(history)),
|
||||
})
|
||||
}
|
||||
|
||||
/// A body name without the suffixes SolidCraft gives the pieces of a split body: ` (1)` for the
|
||||
/// new piece and `'` for a piece kept under its old name, any number of times
|
||||
/// (`Bracket (1) (1)' (1)` → `Bracket`).
|
||||
fn split_base(name: &str) -> &str {
|
||||
pub(crate) fn split_base(name: &str) -> &str {
|
||||
let mut s = name;
|
||||
loop {
|
||||
let t = s.trim_end_matches('\'');
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
//! The feature graph of an opened design: for each body, the features of the SolidCraft timeline
|
||||
//! that built it, with their parameters evaluated and the persistent face and edge names each
|
||||
//! one left on the body.
|
||||
//!
|
||||
//! A design opened from `.solidcraft` is rebuilt from its timeline, so how every body was made is
|
||||
//! known, not guessed: an extrude of a sketch profile along a direction, a box, a shell of a
|
||||
//! thickness, a hole of a diameter. The provenance-driven mesh plan (`mesh.plan`) reads it.
|
||||
//!
|
||||
//! Which features built a body comes from two places: the persistent face names (each names the
|
||||
//! features that made the face, `F8:side:l1`, `F3:inner:F1:+y`, `F82:-z@F84.2` for a pattern
|
||||
//! copy), and the features that name the body (targets, tools, the body a fillet or shell
|
||||
//! works on). A feature whose faces were all removed later and that names no body (a cut that a
|
||||
//! later cut swallowed) is left out.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::Serialize;
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::{Document, FeatureKind, ModelState, Operation, SolvedSketch};
|
||||
use solidcraft_geom::{Seg2, Vec3};
|
||||
use solidcraft_kernel::guard;
|
||||
|
||||
use crate::{Design, Result, Shape};
|
||||
|
||||
/// Most features listed per body (a design's timeline is capped far above any real part).
|
||||
pub const MAX_FEATURES_PER_BODY: usize = 10_000;
|
||||
|
||||
/// The timeline a design was rebuilt from, kept with the design for [`Design::features`].
|
||||
pub(crate) struct History {
|
||||
pub(crate) doc: Document,
|
||||
pub(crate) state: std::sync::Arc<ModelState>,
|
||||
/// Error per feature id, from the rebuild.
|
||||
pub(crate) errors: BTreeMap<u64, String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for History {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("History").field("features", &self.doc.features.len()).field("errors", &self.errors).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The features that built one body, in timeline order.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct BodyFeatures {
|
||||
pub body: String,
|
||||
pub features: Vec<FeatureNode>,
|
||||
}
|
||||
|
||||
/// One feature of the timeline as it built a body.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct FeatureNode {
|
||||
/// The feature's id: `F<id>` in persistent names.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The definition's type (`extrude`, `box`, `cylinder`, `hole`, `shell`, `fillet`, …).
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
/// `new_body`, `join`, `cut` or `intersect`, for features that have one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operation: Option<String>,
|
||||
/// The numeric inputs evaluated: lengths in mm, angles in radians, counts as numbers, keyed
|
||||
/// by their snake-case label (`distance`, `radius`, `thickness`, …).
|
||||
pub values: BTreeMap<String, f64>,
|
||||
/// Where a primitive sits (a box's corner, a cylinder's base, a sphere's centre), mm.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub placement: Option<[f64; 3]>,
|
||||
/// The direction the feature builds along: an extrude's (the sketch normal, flipped for a
|
||||
/// negative extrude), a cylinder's axis, a hole's drilling direction.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<[f64; 3]>,
|
||||
/// An extrude in both directions (`symmetric` or a second distance).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub two_sided: bool,
|
||||
/// The sketch the feature takes its profiles from, with the profiles it uses.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sketch: Option<SketchGeometry>,
|
||||
/// Pattern and mirror copies: each copy's 4×4 transform (row-major), the identity excluded.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub transforms: Vec<[[f64; 4]; 4]>,
|
||||
/// The bodies a join, cut or intersect changes (empty: all), or a combine's target.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub targets: Vec<String>,
|
||||
/// The tool bodies of a combine.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<String>,
|
||||
/// The names the feature gives the bodies it makes (a combine's tool is found by them).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub creates: Vec<String>,
|
||||
/// The definition as stored in the design (expressions unevaluated).
|
||||
pub definition: serde_json::Value,
|
||||
/// The body's faces this feature made or shaped (their persistent names name it).
|
||||
pub faces: Vec<String>,
|
||||
/// The body's edges on those faces.
|
||||
pub edges: Vec<String>,
|
||||
/// Why the feature failed to rebuild, when it did.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A sketch plane and the profiles a feature uses from it (sketch coordinates, mm).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct SketchGeometry {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub origin: [f64; 3],
|
||||
pub x_dir: [f64; 3],
|
||||
pub y_dir: [f64; 3],
|
||||
pub normal: [f64; 3],
|
||||
pub profiles: Vec<ProfileGeometry>,
|
||||
}
|
||||
|
||||
/// A closed profile: its outer loop and holes.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ProfileGeometry {
|
||||
/// Index in the solved sketch's profile list.
|
||||
pub index: usize,
|
||||
pub area: f64,
|
||||
pub outer: Vec<Curve>,
|
||||
pub holes: Vec<Vec<Curve>>,
|
||||
/// The sketch curve ids of the outer loop and of each hole.
|
||||
pub outer_curves: Vec<String>,
|
||||
pub hole_curves: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// A curve of a profile loop (sketch coordinates). A loop of arcs about one centre that closes is
|
||||
/// one [`Curve::Circle`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Curve {
|
||||
Line {
|
||||
a: [f64; 2],
|
||||
b: [f64; 2],
|
||||
},
|
||||
Arc {
|
||||
center: [f64; 2],
|
||||
radius: f64,
|
||||
start: f64,
|
||||
sweep: f64,
|
||||
},
|
||||
Circle {
|
||||
center: [f64; 2],
|
||||
radius: f64,
|
||||
},
|
||||
/// A spline, conic or other curve the planner does not decompose.
|
||||
Other {
|
||||
kind: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn xyz(v: Vec3) -> [f64; 3] {
|
||||
[v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
/// `v` scaled to unit length, components within 1e-12 of zero made zero (a drilling direction
|
||||
/// found from the geometry carries round-off).
|
||||
fn unit(v: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
(n.is_finite() && n > 1e-12).then(|| v.map(|c| if (c / n).abs() < 1e-12 { 0.0 } else { c / n }))
|
||||
}
|
||||
|
||||
/// The ids of the features a persistent face name mentions (`F3:inner:F1:+y` → 3, 1).
|
||||
pub fn feature_ids(name: &str) -> impl Iterator<Item = u64> + '_ {
|
||||
let bytes = name.as_bytes();
|
||||
(0..bytes.len()).filter_map(move |i| {
|
||||
let starts = bytes.get(i) == Some(&b'F') && (i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric()));
|
||||
if !starts {
|
||||
return None;
|
||||
}
|
||||
let digits: &str = name.get(i + 1..)?.split(|c: char| !c.is_ascii_digit()).next()?;
|
||||
if digits.is_empty() { None } else { digits.parse().ok() }
|
||||
})
|
||||
}
|
||||
|
||||
/// `Taper angle` → `taper_angle`, `Distance (0 to 1)` → `distance_0_to_1`.
|
||||
fn snake(label: &str) -> String {
|
||||
let mut out = String::with_capacity(label.len());
|
||||
for c in label.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('_') && !out.is_empty() {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
out.trim_end_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn operation_name(op: Operation) -> &'static str {
|
||||
match op {
|
||||
Operation::NewBody => "new_body",
|
||||
Operation::Join => "join",
|
||||
Operation::Cut => "cut",
|
||||
Operation::Intersect => "intersect",
|
||||
}
|
||||
}
|
||||
|
||||
fn curves(segs: &[Seg2]) -> Vec<Curve> {
|
||||
// A loop of arcs about one centre and radius that turns a full circle is a circle.
|
||||
let arcs: Vec<(solidcraft_geom::Vec2, f64, f64)> =
|
||||
segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, sweep, .. } = s { Some((*center, *radius, *sweep)) } else { None }).collect();
|
||||
if !arcs.is_empty() && arcs.len() == segs.len() {
|
||||
let (c0, r0, _) = arcs[0];
|
||||
let same = arcs.iter().all(|(c, r, _)| (c.x - c0.x).abs() < 1e-9 && (c.y - c0.y).abs() < 1e-9 && (r - r0).abs() < 1e-9);
|
||||
let turn: f64 = arcs.iter().map(|a| a.2).sum();
|
||||
if same && (turn.abs() - std::f64::consts::TAU).abs() < 1e-6 {
|
||||
return vec![Curve::Circle { center: [c0.x, c0.y], radius: r0 }];
|
||||
}
|
||||
}
|
||||
segs.iter()
|
||||
.map(|s| match s {
|
||||
Seg2::Line { a, b } => Curve::Line { a: [a.x, a.y], b: [b.x, b.y] },
|
||||
Seg2::Arc { center, radius, start, sweep } => Curve::Arc { center: [center.x, center.y], radius: *radius, start: *start, sweep: *sweep },
|
||||
other => Curve::Other { kind: format!("{other:?}").split(['{', ' ', '(']).next().unwrap_or("curve").to_ascii_lowercase() },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sketch_geometry(ss: &SolvedSketch, sel: Option<&solidcraft_doc::ProfileSel>) -> SketchGeometry {
|
||||
let indices: Vec<usize> = match sel {
|
||||
Some(sel) => solidcraft_doc::profile_selection_indices(ss, sel).unwrap_or_else(|_| (0..ss.profiles.len()).collect()),
|
||||
None => (0..ss.profiles.len()).collect(),
|
||||
};
|
||||
let profiles = indices
|
||||
.into_iter()
|
||||
.filter_map(|i| {
|
||||
let p = ss.profiles.get(i)?;
|
||||
Some(ProfileGeometry {
|
||||
index: i,
|
||||
area: p.area,
|
||||
outer: curves(&p.region.outer.segs),
|
||||
holes: p.region.holes.iter().map(|h| curves(&h.segs)).collect(),
|
||||
outer_curves: p.outer_curves.clone(),
|
||||
hole_curves: p.hole_curves.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
SketchGeometry {
|
||||
id: ss.feature,
|
||||
name: ss.name.clone(),
|
||||
origin: xyz(ss.plane.origin),
|
||||
x_dir: xyz(ss.plane.x),
|
||||
y_dir: xyz(ss.plane.y),
|
||||
normal: xyz(ss.plane.normal()),
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// The feature `id` of the timeline, described against `body` (its faces and edges).
|
||||
fn node(&self, f: &solidcraft_doc::Feature, faces: Vec<String>, edges: Vec<String>) -> FeatureNode {
|
||||
let (vals, _) = self.doc.param_values();
|
||||
let eval = |e: &str, k: Kind| Document::eval_in(&vals, e, k).ok().filter(|v| v.is_finite());
|
||||
let mut values = BTreeMap::new();
|
||||
for (label, e, k) in f.kind.inputs() {
|
||||
if let Some(v) = eval(&e, k) {
|
||||
let mut key = snake(label);
|
||||
let mut n = 2;
|
||||
while values.contains_key(&key) {
|
||||
key = format!("{}_{n}", snake(label));
|
||||
n += 1;
|
||||
}
|
||||
values.insert(key, v);
|
||||
}
|
||||
}
|
||||
let point =
|
||||
|p: &[String; 3]| -> Option<[f64; 3]> { Some([eval(&p[0], Kind::Length)?, eval(&p[1], Kind::Length)?, eval(&p[2], Kind::Length)?]) };
|
||||
let sketch_of = |id: u64| self.state.sketch(id);
|
||||
let mut node = FeatureNode {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: String::new(),
|
||||
operation: None,
|
||||
values,
|
||||
placement: None,
|
||||
direction: None,
|
||||
two_sided: false,
|
||||
sketch: None,
|
||||
transforms: Vec::new(),
|
||||
targets: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
creates: f.body_names.clone(),
|
||||
definition: serde_json::to_value(&f.kind).unwrap_or_default(),
|
||||
faces,
|
||||
edges,
|
||||
error: self.errors.get(&f.id).cloned(),
|
||||
};
|
||||
node.kind = node.definition.get("type").and_then(serde_json::Value::as_str).unwrap_or("feature").to_string();
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.targets = targets.clone();
|
||||
if let Some(ss) = sketch_of(*sketch) {
|
||||
let n = xyz(ss.plane.normal());
|
||||
let negative = matches!(extent.direction, solidcraft_doc::Direction::Negative);
|
||||
node.direction = unit(if negative { n.map(|c| -c) } else { n });
|
||||
node.two_sided = matches!(extent.direction, solidcraft_doc::Direction::Symmetric) || extent.distance2.is_some();
|
||||
node.sketch = Some(sketch_geometry(ss, Some(profiles)));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, operation, targets, .. } | FeatureKind::Sweep { sketch, profiles, operation, targets, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.targets = targets.clone();
|
||||
node.sketch = sketch_of(*sketch).map(|ss| sketch_geometry(ss, Some(profiles)));
|
||||
}
|
||||
FeatureKind::Loft { operation, targets, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.targets = targets.clone();
|
||||
}
|
||||
FeatureKind::Box { corner, operation, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.placement = point(corner);
|
||||
node.direction = Some([0.0, 0.0, 1.0]);
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, operation, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.placement = point(base);
|
||||
node.direction = unit(xyz(*axis));
|
||||
}
|
||||
FeatureKind::Sphere { center, operation, .. } | FeatureKind::Torus { center, operation, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.placement = point(center);
|
||||
}
|
||||
FeatureKind::Hole { position, direction, .. } => {
|
||||
node.operation = Some("cut".into());
|
||||
node.placement = Some(xyz(*position));
|
||||
node.direction = unit(xyz(*direction));
|
||||
}
|
||||
FeatureKind::Combine { target, tools, operation, .. } => {
|
||||
node.operation = Some(operation_name(*operation).into());
|
||||
node.targets = vec![target.clone()];
|
||||
node.tools = tools.clone();
|
||||
}
|
||||
FeatureKind::Shell { body, .. } | FeatureKind::Fillet { body, .. } | FeatureKind::Chamfer { body, .. } => {
|
||||
node.targets = body.iter().cloned().collect();
|
||||
}
|
||||
FeatureKind::Split { body, .. } => node.targets = vec![body.clone()],
|
||||
FeatureKind::Pattern { pattern, bodies, .. } => {
|
||||
node.targets = bodies.clone();
|
||||
if let Ok(m) = solidcraft_doc::pattern_matrices(&self.doc, &self.state, pattern) {
|
||||
// SolidCraft's matrices are column-major (`m[3]` the translation); the graph's are row-major.
|
||||
let rows = |m: [[f64; 4]; 4]| std::array::from_fn(|i| std::array::from_fn(|j| m[j][i]));
|
||||
node.transforms = m.into_iter().filter(|m| *m != solidcraft_doc::IDENTITY).map(rows).collect();
|
||||
}
|
||||
}
|
||||
FeatureKind::Mirror { plane, bodies, .. } => {
|
||||
node.targets = bodies.clone();
|
||||
if let Ok(p) = self.doc.resolve_plane_in(&vals, plane, 0, Some(&self.state))
|
||||
&& let Some(n) = unit(xyz(p.normal()))
|
||||
{
|
||||
// x' = x − 2 ((x − o)·n) n
|
||||
let o = xyz(p.origin);
|
||||
let d = 2.0 * (o[0] * n[0] + o[1] * n[1] + o[2] * n[2]);
|
||||
let mut m = solidcraft_doc::IDENTITY;
|
||||
for (i, row) in m.iter_mut().take(3).enumerate() {
|
||||
for (j, c) in row.iter_mut().take(3).enumerate() {
|
||||
*c -= 2.0 * n[i] * n[j];
|
||||
}
|
||||
row[3] = d * n[i];
|
||||
}
|
||||
node.transforms = vec![m];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
impl Design {
|
||||
/// The features that built each body of the design as opened (`body`: only that one), in
|
||||
/// timeline order. Partition cells are described by the body they were cut from. An error
|
||||
/// for a design not opened from a timeline (shapes built in code) or a body it lacks.
|
||||
pub fn features(&self, body: Option<&str>) -> Result<Vec<BodyFeatures>> {
|
||||
let history = self.history.as_deref().ok_or_else(|| crate::Error::Invalid("the design has no feature timeline".into()))?;
|
||||
let shapes: Vec<&Shape> = match body {
|
||||
Some(name) => {
|
||||
let source = self.source_of(name).ok_or_else(|| crate::Error::Invalid(format!("no body `{name}`")))?;
|
||||
self.source.iter().filter(|b| b.name == source).collect()
|
||||
}
|
||||
None => self.source.iter().collect(),
|
||||
};
|
||||
shapes.into_iter().map(|shape| guard("feature graph", || Ok(body_features(history, shape))).map_err(crate::Error::from)?).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn body_features(history: &History, shape: &Shape) -> Result<BodyFeatures> {
|
||||
// Faces by the features their names mention.
|
||||
let mut faces: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in shape.faces() {
|
||||
let ids: BTreeSet<u64> = feature_ids(&f.name).collect();
|
||||
for id in ids {
|
||||
faces.entry(id).or_default().push(f.name.clone());
|
||||
}
|
||||
}
|
||||
let edges = shape.edges()?;
|
||||
// The body as SolidCraft names it before split pieces were numbered (`Bracket.2` was
|
||||
// `Bracket (1)`): features name it by its base.
|
||||
let base = shape.name.rsplit_once('.').filter(|(_, n)| n.bytes().all(|b| b.is_ascii_digit())).map_or(shape.name.as_str(), |(b, _)| b);
|
||||
let names_body = |f: &solidcraft_doc::Feature| {
|
||||
let mentioned = |n: &String| n == &shape.name || n == base || crate::design::split_base(n) == base;
|
||||
// The bodies the feature changes. A combine's tools are not among them: kept tools come
|
||||
// out unchanged, and the others are gone.
|
||||
let targets: Vec<&String> = match &f.kind {
|
||||
FeatureKind::Extrude { targets, .. }
|
||||
| FeatureKind::Revolve { targets, .. }
|
||||
| FeatureKind::Sweep { targets, .. }
|
||||
| FeatureKind::Loft { targets, .. } => targets.iter().collect(),
|
||||
FeatureKind::Combine { target, .. } => vec![target],
|
||||
FeatureKind::Shell { body, .. } | FeatureKind::Fillet { body, .. } | FeatureKind::Chamfer { body, .. } => body.iter().collect(),
|
||||
FeatureKind::Split { body, .. } => vec![body],
|
||||
// A body pattern or mirror: the original is the instance the copies repeat.
|
||||
FeatureKind::Pattern { bodies, .. } | FeatureKind::Mirror { bodies, .. } => bodies.iter().collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
targets.into_iter().chain(&f.body_names).any(mentioned)
|
||||
};
|
||||
let mut features = Vec::new();
|
||||
for f in history.doc.features.iter().filter(|f| !f.suppressed) {
|
||||
if matches!(
|
||||
f.kind,
|
||||
FeatureKind::Sketch { .. }
|
||||
| FeatureKind::ConstructionPlane { .. }
|
||||
| FeatureKind::ConstructionAxis { .. }
|
||||
| FeatureKind::ConstructionPoint { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let own = faces.remove(&f.id).unwrap_or_default();
|
||||
if own.is_empty() && !names_body(f) {
|
||||
continue;
|
||||
}
|
||||
let tagged: BTreeSet<u32> = own.iter().filter_map(|n| shape.face_index(n)).collect();
|
||||
let on: Vec<String> = edges.iter().filter(|e| e.faces.iter().any(|i| tagged.contains(i))).map(|e| e.name.clone()).collect();
|
||||
features.push(history.node(f, own, on));
|
||||
if features.len() >= MAX_FEATURES_PER_BODY {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(BodyFeatures { body: shape.name.clone(), features })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feature_ids_come_from_every_name_part() {
|
||||
let ids = |n: &str| feature_ids(n).collect::<Vec<_>>();
|
||||
assert_eq!(ids("F3:inner:F2:blend:F1:+y|F1:-x"), vec![3, 2, 1, 1]);
|
||||
assert_eq!(ids("F82:-z@F84.2"), vec![82, 84]);
|
||||
assert_eq!(ids("F44:side:l12"), vec![44]);
|
||||
assert_eq!(ids("partition:Half:a"), Vec::<u64>::new());
|
||||
assert_eq!(ids("OFF7"), Vec::<u64>::new());
|
||||
assert_eq!(ids("F"), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plate_is_an_extrude_of_its_outline_and_a_cut_of_a_circle() {
|
||||
use crate::sample::{Plate, plate_design};
|
||||
let plate = Plate::default();
|
||||
let design = crate::open_design(&plate_design(&plate).unwrap()).unwrap();
|
||||
let graph = design.features(None).unwrap();
|
||||
assert_eq!(graph.len(), 1);
|
||||
let f = &graph[0].features;
|
||||
assert_eq!(
|
||||
f.iter().map(|n| (n.kind.as_str(), n.operation.as_deref())).collect::<Vec<_>>(),
|
||||
[("extrude", Some("new_body")), ("extrude", Some("cut"))]
|
||||
);
|
||||
let (outline, hole) = (&f[0], &f[1]);
|
||||
// Expressions come out evaluated: the thickness parameter, and `thickness + 1 mm`.
|
||||
assert_eq!(outline.values.get("distance"), Some(&plate.thickness));
|
||||
assert_eq!(hole.values.get("distance"), Some(&(plate.thickness + 1.0)));
|
||||
assert_eq!(outline.direction, Some([0.0, 0.0, 1.0]));
|
||||
let sketch = outline.sketch.as_ref().unwrap();
|
||||
assert_eq!(sketch.normal, [0.0, 0.0, 1.0]);
|
||||
assert_eq!(sketch.profiles.len(), 1);
|
||||
assert!(sketch.profiles[0].outer.iter().all(|c| matches!(c, Curve::Line { .. })) && sketch.profiles[0].outer.len() == 4);
|
||||
let circle = &hole.sketch.as_ref().unwrap().profiles[0].outer;
|
||||
let c = plate.depth / 2.0;
|
||||
assert_eq!(circle, &[Curve::Circle { center: [c, c], radius: plate.hole_diameter / 2.0 }]);
|
||||
// Each feature's faces: the outline's four sides and caps, the hole's two halves.
|
||||
assert_eq!(outline.faces.len(), 6, "{:?}", outline.faces);
|
||||
assert_eq!(hole.faces.len(), 2, "{:?}", hole.faces);
|
||||
assert!(!hole.edges.is_empty());
|
||||
assert!(outline.error.is_none() && hole.error.is_none());
|
||||
// Bodies the design lacks are an error, not an empty graph.
|
||||
assert!(design.features(Some("Nope")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_become_snake_case_keys() {
|
||||
assert_eq!(snake("Taper angle"), "taper_angle");
|
||||
assert_eq!(snake("Distance (0 to 1)"), "distance_0_to_1");
|
||||
assert_eq!(snake("Radius"), "radius");
|
||||
}
|
||||
|
||||
/// Primitives, a shell, a hole, a combine, a body pattern and a mirror: each body lists the
|
||||
/// features that built it, with their placements, directions, targets and transforms.
|
||||
#[cfg(feature = "scripts")]
|
||||
#[test]
|
||||
fn a_timeline_of_primitives_and_copies_is_listed_per_body() {
|
||||
let script = r#"[
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Case"}},
|
||||
{"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "body": "Case"}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 6, "height": 5, "base": [10, 10, 2], "operation": "join", "targets": ["Case"]}},
|
||||
{"command": "solid.hole", "params": {"position": [10, 10, 7], "diameter": 2, "depth": 4}},
|
||||
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 20, "corner": [30, 20, 0], "body_name": "Slot"}},
|
||||
{"command": "solid.combine", "params": {"target": "Case", "tools": ["Slot"], "operation": "cut"}},
|
||||
{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 3, "corner": [60, 0, 0], "body_name": "Pin"}},
|
||||
{"command": "solid.pattern.rectangular", "params": {"bodies": ["Pin"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 2}},
|
||||
{"command": "solid.box", "params": {"length": 2, "width": 2, "height": 2, "corner": [0, 50, 0], "body_name": "Foot"}},
|
||||
{"command": "solid.mirror", "params": {"bodies": ["Foot"], "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}}
|
||||
]"#;
|
||||
let design = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
|
||||
let case = design.features(Some("Case")).unwrap();
|
||||
let kinds: Vec<&str> = case[0].features.iter().map(|f| f.kind.as_str()).collect();
|
||||
assert_eq!(kinds, ["box", "shell", "cylinder", "hole", "box", "combine"], "{case:#?}");
|
||||
let f = &case[0].features;
|
||||
assert_eq!(f[0].placement, Some([0.0, 0.0, 0.0]));
|
||||
assert_eq!((f[0].values["length"], f[0].values["width"], f[0].values["height"]), (40.0, 30.0, 20.0));
|
||||
assert_eq!(f[1].values["thickness"], 2.0);
|
||||
assert_eq!((f[2].values["radius"], f[2].direction, f[2].operation.as_deref()), (3.0, Some([0.0, 0.0, 1.0]), Some("join")));
|
||||
assert_eq!((f[3].values["diameter"], f[3].direction, f[3].placement), (2.0, Some([0.0, 0.0, -1.0]), Some([10.0, 10.0, 7.0])));
|
||||
assert_eq!((f[5].targets.as_slice(), f[5].tools.as_slice()), (&["Case".to_string()][..], &["Slot".to_string()][..]));
|
||||
// The cut tool's faces are the slot's walls in the case.
|
||||
assert!(!f[4].faces.is_empty());
|
||||
// The pattern's copies: each a body of its own with the pattern's transforms.
|
||||
let pins: Vec<&str> = design.bodies.iter().map(|b| b.name.as_str()).filter(|n| n.starts_with("Pin")).collect();
|
||||
assert_eq!(pins.len(), 3, "{pins:?}");
|
||||
for pin in pins {
|
||||
let g = design.features(Some(pin)).unwrap();
|
||||
let kinds: Vec<&str> = g[0].features.iter().map(|f| f.kind.as_str()).collect();
|
||||
assert_eq!(kinds, ["box", "pattern"], "{pin}");
|
||||
let t = &g[0].features[1].transforms;
|
||||
assert_eq!(t.len(), 2);
|
||||
assert!(t.iter().any(|m| (m[0][3] - 2.0).abs() < 1e-9) && t.iter().any(|m| (m[0][3] - 4.0).abs() < 1e-9), "{t:?}");
|
||||
}
|
||||
// The mirror: x' = 20 − x.
|
||||
let foot = design.bodies.iter().find(|b| b.name.starts_with("Foot") && b.name != "Foot").map(|b| b.name.clone()).unwrap_or_default();
|
||||
let g = design.features(Some(&foot)).unwrap();
|
||||
let mirror = g[0].features.iter().find(|f| f.kind == "mirror").unwrap_or_else(|| panic!("{foot}: {g:#?}"));
|
||||
assert_eq!(mirror.transforms, vec![[[-1.0, 0.0, 0.0, 20.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]]);
|
||||
// The cut tool is gone; the graph of every body comes in the design's order.
|
||||
let all = design.features(None).unwrap();
|
||||
assert_eq!(all.iter().map(|b| b.body.as_str()).collect::<Vec<_>>(), design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
mod design;
|
||||
mod edges;
|
||||
pub mod features;
|
||||
pub mod partition;
|
||||
pub mod sample;
|
||||
#[cfg(feature = "scripts")]
|
||||
@@ -26,6 +27,7 @@ mod script;
|
||||
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design};
|
||||
pub use edges::{EDGE_TOLERANCE, Edge, Vertex};
|
||||
pub use features::{BodyFeatures, Curve, FeatureNode, ProfileGeometry, SketchGeometry};
|
||||
pub use partition::{Cut, CutReport};
|
||||
pub mod tagged_mesh;
|
||||
#[cfg(feature = "scripts")]
|
||||
|
||||
@@ -40,7 +40,8 @@ pub struct Cut {
|
||||
pub point: [f64; 3],
|
||||
pub normal: [f64; 3],
|
||||
/// The bodies to cut, by name, as they are when this partition comes (the cells of earlier
|
||||
/// partitions included); empty: every body the plane divides.
|
||||
/// partitions included); a body earlier partitions cut stands for its cells (`B` for `B/a`,
|
||||
/// `B/b/a`, …); empty: every body the plane divides.
|
||||
pub bodies: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -104,39 +105,45 @@ impl Design {
|
||||
let mut reports = Vec::with_capacity(cuts.len());
|
||||
for (cut, (plane, n)) in cuts.iter().zip(&planes) {
|
||||
let feature = format!("partition {}", cut.name);
|
||||
// A listed name is the body of that name, or the cells earlier partitions cut it into.
|
||||
let names = |name: &str, b: &str| name == b || name.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'));
|
||||
for b in &cut.bodies {
|
||||
if !bodies.iter().any(|x| &x.name == b) {
|
||||
if !bodies.iter().any(|x| names(&x.name, b)) {
|
||||
errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut"), bodies: vec![b.clone()] });
|
||||
}
|
||||
}
|
||||
let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0, warnings: Vec::new() };
|
||||
let mut next = Vec::with_capacity(bodies.len() + 1);
|
||||
// The listed names some body of which the plane divides.
|
||||
let mut divided = vec![false; cut.bodies.len()];
|
||||
for body in bodies {
|
||||
let listed = cut.bodies.iter().any(|b| b == &body.name);
|
||||
if !(cut.bodies.is_empty() || listed) {
|
||||
let listed: Vec<usize> = (0..cut.bodies.len()).filter(|&i| cut.bodies.get(i).is_some_and(|b| names(&body.name, b))).collect();
|
||||
if !(cut.bodies.is_empty() || !listed.is_empty()) {
|
||||
next.push(body);
|
||||
continue;
|
||||
}
|
||||
match split(&body, plane, *n, cut)? {
|
||||
Split::Cells(cells, faces, warnings) => {
|
||||
for &i in &listed {
|
||||
if let Some(d) = divided.get_mut(i) {
|
||||
*d = true;
|
||||
}
|
||||
}
|
||||
report.split.push(body.name.clone());
|
||||
report.faces += faces;
|
||||
report.warnings.extend(warnings);
|
||||
next.extend(cells);
|
||||
}
|
||||
Split::Misses => {
|
||||
if listed {
|
||||
errors.push(FeatureError {
|
||||
feature: feature.clone(),
|
||||
message: format!("the plane does not divide `{}`", body.name),
|
||||
bodies: vec![body.name.clone()],
|
||||
});
|
||||
}
|
||||
next.push(body);
|
||||
}
|
||||
Split::Misses => next.push(body),
|
||||
// Listed or not: the plane may well divide the body, and a cut that silently
|
||||
// leaves it whole would be mistaken for one that does not reach it (#283).
|
||||
Split::Failed(e) => {
|
||||
// Reported here; not again as a plane that divides nothing.
|
||||
for &i in &listed {
|
||||
if let Some(d) = divided.get_mut(i) {
|
||||
*d = true;
|
||||
}
|
||||
}
|
||||
errors.push(FeatureError {
|
||||
feature: feature.clone(),
|
||||
message: format!("the kernel could not cut `{}` ({e})", body.name),
|
||||
@@ -149,10 +156,28 @@ impl Design {
|
||||
return Err(Error::Invalid(format!("the partitions make at most {MAX_CELLS} cells")));
|
||||
}
|
||||
}
|
||||
for (b, divided) in cut.bodies.iter().zip(divided) {
|
||||
// A body that is not there is reported above; one that is, and that the plane
|
||||
// misses (all of its cells), is an error: the cut would do nothing to it.
|
||||
if !divided && next.iter().any(|x| names(&x.name, b)) {
|
||||
errors.push(FeatureError {
|
||||
feature: feature.clone(),
|
||||
message: format!("the plane does not divide `{b}`"),
|
||||
bodies: vec![b.clone()],
|
||||
});
|
||||
}
|
||||
}
|
||||
bodies = next;
|
||||
reports.push(report);
|
||||
}
|
||||
Ok(Design { name: self.name.clone(), bodies, errors, cuts: reports, source: std::sync::Arc::clone(&self.source) })
|
||||
Ok(Design {
|
||||
name: self.name.clone(),
|
||||
bodies,
|
||||
errors,
|
||||
cuts: reports,
|
||||
source: std::sync::Arc::clone(&self.source),
|
||||
history: self.history.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +569,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A body cut before stands for its cells: listing it cuts the cells the plane divides, and
|
||||
/// a plane that misses all of them is an error, as for a whole body.
|
||||
#[test]
|
||||
fn a_cut_body_listed_again_cuts_its_cells() {
|
||||
let d = plate();
|
||||
let body = d.bodies[0].name.clone();
|
||||
let on = |name: &str, point, normal| Cut { bodies: vec![body.clone()], ..cut(name, point, normal) };
|
||||
let p = d.partitioned(&[on("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), on("Z", [0.0, 0.0, 5.0], [0.0, 0.0, 1.0])]).unwrap();
|
||||
assert!(p.errors.is_empty(), "{:?}", p.errors);
|
||||
assert_eq!(p.bodies.len(), 4);
|
||||
// Only one cell (x < 30) reaches x = 20.
|
||||
let p = d.partitioned(&[on("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), on("Y", [20.0, 0.0, 0.0], [1.0, 0.0, 0.0])]).unwrap();
|
||||
assert!(p.errors.is_empty(), "{:?}", p.errors);
|
||||
assert_eq!(p.bodies.len(), 3);
|
||||
let p = d.partitioned(&[on("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), on("Out", [100.0, 0.0, 0.0], [1.0, 0.0, 0.0])]).unwrap();
|
||||
assert_eq!(p.errors.iter().map(|e| e.feature.as_str()).collect::<Vec<_>>(), ["partition Out"]);
|
||||
assert_eq!(p.bodies.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plane_that_misses_or_a_missing_body_is_reported_not_fatal() {
|
||||
let d = plate();
|
||||
|
||||
Reference in new issue
Block a user