mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
Rebase onto main: what the merged code needs to build together
The branch rebased onto main keeps each of its commits; where both sides changed the same code, these are the pieces neither side's commit had. - Cargo: one [features] table in the CLI's manifest (two met in the merge). - geom: a NURBS surface's `closed` set once in a placed copy (both sides added it). - kernel: the whole-face extrude tools (#2040) work around truck's boolean on truck types, which left the workspace with the SMLib kernel; the module, its tests and the doc crate's tool and tests go. The display planner's flat fill (tess/fallback.rs) has no caller with the schema 4 mesher, and the tessellator's seam and uv modules are the branch's. A body's boundary tolerance from a STEP file belongs to the truck body the schema 4 record replaced: `with_boundary_tol` goes. `unmeshed_faces` counts the coarse mesh as the branch does. A loft's section re-split passes the lead's perimeter shares to main's `resample` (a circle breaks there when the matched corners do not go round in order). Main's tests of the mesh-based measure (the SMLib kernel measures exactly) and of the IGES preprocessor name (no such field on the branch's import record) go. - doc: a feature result carries no boolean diagnostics on the branch, so the model cache neither writes nor reads them, and a pattern copy's miss test runs the intersect as it is. A placed body's worker result is a Result: a body taken from the previous placement is Ok. A thread's clipboard remap takes main's arguments, the tapped catalogue names a metric size by ThreadForm::Metric, and main's tests unwrap the placing calls that return their error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,8 +23,6 @@ path = "src/alias.rs"
|
||||
default = ["raytrace-cpu"]
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
|
||||
|
||||
[features]
|
||||
# Link SMLib into the kernel, for the regression rig's smlib runs (tests/kernel-cases).
|
||||
smlib = ["solidcraft-engine/smlib"]
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ impl crate::Feature {
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
|
||||
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, bodies, 0)?;
|
||||
|
||||
+2
-87
@@ -4205,85 +4205,6 @@ struct ExtrudeConstructor {
|
||||
interval: (f64, f64),
|
||||
}
|
||||
|
||||
/// The tool of an Extrude whose profile is a whole planar face of its one target (the face's
|
||||
/// outline projected into the sketch), started at the face (#2040). The projection fits an edge
|
||||
/// that is no line or arc, so the profile's tool lies a hair off the body all round its outline,
|
||||
/// and the boolean fails there. A join takes the body's own face swept instead, which is sewn on
|
||||
/// along it. A cut whose walls take nothing from the body (all of it within the depth lies over
|
||||
/// the face) takes a slab beyond the depth plane instead, which meets the body only across that
|
||||
/// plane; one whose walls cut into the body at some edges keeps those walls, exactly, and moves
|
||||
/// the others off it (`kernel::cut_tool`). `None` (the profile's own tool) otherwise. With the
|
||||
/// interval along the normal it spans.
|
||||
fn whole_face_tool(
|
||||
st: &ModelState,
|
||||
targets: &[String],
|
||||
ss: &SolvedSketch,
|
||||
r: &Region2,
|
||||
operation: Operation,
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
) -> Option<(Traced, (f64, f64))> {
|
||||
if !matches!(operation, Operation::Join | Operation::Cut) || hi - lo <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// An outline of lines and arcs is the face's own: the profile's tool already meets the body
|
||||
// exactly, and keeps the names later features attach to (#489).
|
||||
if r.outer.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. } | solidcraft_geom::Seg2::Arc { .. })) {
|
||||
return None;
|
||||
}
|
||||
let mut candidates = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name));
|
||||
let target = candidates.next()?;
|
||||
if candidates.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let face = kernel::matching_face(&target.body, &ss.plane, r, &[lo, hi])?;
|
||||
let n = ss.plane.normal();
|
||||
let tol = target.body.size() * 1e-6 + 1e-9;
|
||||
let up = face.normal.dot(n) > 0.0;
|
||||
let (at_lo, at_hi) = ((face.height - lo).abs() <= tol, (face.height - hi).abs() <= tol);
|
||||
let roles = |region| crate::lineage::ProfileRoles {
|
||||
sketch: Some(ss),
|
||||
region: Some(region),
|
||||
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
|
||||
thin: None,
|
||||
};
|
||||
match operation {
|
||||
// Out of the body from the face.
|
||||
Operation::Join if (up && at_lo) || (!up && at_hi) => {
|
||||
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss), None).ok()?;
|
||||
let [built] = built.as_slice() else { return None };
|
||||
let exact = kernel::face_prism(&target.body, face, hi - lo, true, &built.body).ok()?;
|
||||
kernel::can_join_touching_faces(&target.body, &exact).then(|| (roles(r).tool(exact, Some(&built.body)), (lo, hi)))
|
||||
}
|
||||
// Into the body from the face, its walls touching it at most.
|
||||
Operation::Cut if ((up && at_hi) || (!up && at_lo)) && !kernel::cut_takes_only_the_face(&target.body, face, hi - lo) => {
|
||||
// Some walls go into the body: those that only touch it are moved off.
|
||||
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss), None).ok()?;
|
||||
let [built] = built.as_slice() else { return None };
|
||||
let (tool, off) = kernel::cut_tool(&target.body, face, hi - lo, Some(&built.body))?;
|
||||
let span = if up { (lo, hi + off) } else { (lo - off, hi) };
|
||||
Some((roles(r).tool(tool, Some(&built.body)), span))
|
||||
}
|
||||
Operation::Cut if (up && at_hi) || (!up && at_lo) => {
|
||||
// Over the whole body, from the depth plane out past it.
|
||||
let reach = target.body.size() * 2.0 + (hi - lo);
|
||||
let c = r.outer.centroid();
|
||||
let p = |x: f64, y: f64| Vec2::new(c.x + x * reach, c.y + y * reach);
|
||||
let line = |a: Vec2, b: Vec2| solidcraft_geom::Seg2::Line { a, b };
|
||||
let corners = [p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0), p(-1.0, 1.0)];
|
||||
let segs = (0..4).filter_map(|i| Some(line(*corners.get(i)?, *corners.get((i + 1) % 4)?))).collect();
|
||||
let slab = Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: vec![] };
|
||||
let (l, h) = if up { (lo, hi + reach) } else { (lo - reach, hi) };
|
||||
let built = centered_extrude(&ss.plane, &slab, l, h, None, None).ok()?;
|
||||
let [built] = built.as_slice() else { return None };
|
||||
#[cfg(test)]
|
||||
whole_face_tests::slab_taken();
|
||||
Some((roles(&slab).tool(built.body.clone(), None), (l, h)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct planar caps locally before placing the completed tool.
|
||||
fn centered_extrude(
|
||||
plane: &Plane,
|
||||
@@ -5538,10 +5459,8 @@ fn copy_misses(st: &ModelState, f: &Feature, targets: &[String], tools: &[Traced
|
||||
.filter(|b| all || if targets.is_empty() { b.component == f.component || b.feature == f.id } else { targets.contains(&b.name) })
|
||||
.all(|b| {
|
||||
tools.iter().all(|t| {
|
||||
// A diagnostic must neither reject an otherwise valid cut nor add its own
|
||||
// retries to the feature's report. An inconclusive probe is not a miss.
|
||||
let (intersection, _) = kernel::capture_boolean_diagnostics(|| kernel::boolean(&b.body, &t.body, BoolOp::Intersect));
|
||||
matches!(intersection, Ok(None))
|
||||
// An inconclusive probe is not a miss: it must not reject an otherwise valid cut.
|
||||
matches!(kernel::boolean(&b.body, &t.body, BoolOp::Intersect), Ok(None))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -7811,10 +7730,6 @@ mod chain_tests {
|
||||
#[path = "eval_cost_tests.rs"]
|
||||
mod cost_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "eval_whole_face_tests.rs"]
|
||||
mod whole_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod body_name_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -245,7 +245,7 @@ fn editing_a_sketch_on_a_turned_occurrence_reuses_its_frame_bodies() {
|
||||
let state = model.state();
|
||||
let raw = state.bodies.first().unwrap();
|
||||
let index = raw.body.face_sources().unwrap().iter().position(|s| matches!(s, Some(kernel::FaceSource::Box { axis: 2, high: true, .. }))).unwrap();
|
||||
let world = world_state(&doc, &state);
|
||||
let world = world_state(&doc, &state).unwrap();
|
||||
let shown = world.bodies.first().unwrap();
|
||||
let picked = shown.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(raw, index, &picked, &path, 0, &[]).unwrap();
|
||||
@@ -469,8 +469,8 @@ fn placed_bodies_taken_as_before_keep_their_measure() {
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 8.0).unwrap(), 1));
|
||||
let mut cache = PlacedBodies::default();
|
||||
let before = world_state_reusing(&doc, &state, &mut cache);
|
||||
let before = world_state_reusing(&doc, &state, &mut cache).unwrap();
|
||||
let centroid = before.bodies[0].measure().unwrap().centroid;
|
||||
let again = world_state_reusing(&doc, &state, &mut cache);
|
||||
let again = world_state_reusing(&doc, &state, &mut cache).unwrap();
|
||||
assert_eq!(counted(|| assert_eq!(again.bodies[0].measure().unwrap().centroid, centroid)).measures, 0);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the
|
||||
//! tool `whole_face_tool` picks.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::{Plane, Vec2, Vec3};
|
||||
use solidcraft_kernel as kernel;
|
||||
use solidcraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
use crate::*;
|
||||
|
||||
thread_local! {
|
||||
static SLABS: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A cut took a slab over the whole body as its tool, on this thread.
|
||||
pub(super) fn slab_taken() {
|
||||
SLABS.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
|
||||
/// A closed spline through six points round the origin, on the plane at height `z`.
|
||||
fn spline_sketch(z: f64) -> FeatureKind {
|
||||
let mut sk = Sketch::new();
|
||||
let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)];
|
||||
let mut pts: Vec<usize> = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
pts.push(pts[0]);
|
||||
sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
|
||||
FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk }
|
||||
}
|
||||
|
||||
fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) {
|
||||
let kind = FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
extent: serde_json::from_value(extent).unwrap(),
|
||||
operation,
|
||||
targets: vec![],
|
||||
};
|
||||
doc.add_feature(kind, None).unwrap();
|
||||
}
|
||||
|
||||
fn volume(model: &Model) -> f64 {
|
||||
let state = model.state();
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity());
|
||||
kernel::measure(&state.bodies[0].body).unwrap().volume
|
||||
}
|
||||
|
||||
/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole
|
||||
/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3
|
||||
/// below the face up: it must take the face's area times 3 and leave the plate alone.
|
||||
#[test]
|
||||
fn slab_cut_into_a_spline_prism() {
|
||||
let mut doc = Document::new("spline prism");
|
||||
let plate = FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)),
|
||||
length: "40 mm".into(),
|
||||
width: "40 mm".into(),
|
||||
height: "5 mm".into(),
|
||||
operation: Operation::NewBody,
|
||||
};
|
||||
doc.add_feature(plate, None).unwrap();
|
||||
let base = doc.add_feature(spline_sketch(5.0), None).unwrap();
|
||||
extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let joined = volume(&model);
|
||||
let area = (joined - 40.0 * 40.0 * 5.0) / 10.0;
|
||||
assert!(area > 100.0, "the prism's area {area}");
|
||||
|
||||
let top = doc.add_feature(spline_sketch(15.0), None).unwrap();
|
||||
extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut);
|
||||
SLABS.with(|n| n.set(0));
|
||||
model.evaluate(&doc);
|
||||
let cut = volume(&model);
|
||||
assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab");
|
||||
assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0);
|
||||
let bounds = model.state().bodies[0].mesh().bounds();
|
||||
assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z);
|
||||
assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min);
|
||||
}
|
||||
@@ -25,17 +25,6 @@ const MAX_ITEMS: usize = 1_000_000;
|
||||
/// Bodies, sketches and sheet bodies all the states hold together, at most (a state is a list of clones).
|
||||
const MAX_HELD: usize = 50_000_000;
|
||||
|
||||
/// A feature's boolean retries as saved. The kernel leaves `rejected` out when it is empty, and
|
||||
/// the field has no default when read, so a saved model whose booleans retried was refused and
|
||||
/// the design evaluated on every open (#5286): an absent list is an empty one.
|
||||
fn saved_diagnostics(d: &Json) -> Result<kernel::BooleanDiagnostics> {
|
||||
let mut d = d.clone();
|
||||
if let Some(fields) = d.as_object_mut() {
|
||||
fields.entry("rejected").or_insert_with(|| json!([]));
|
||||
}
|
||||
serde_json::from_value(d).map_err(|_| bad("saved model: diagnostics"))
|
||||
}
|
||||
|
||||
fn hex(v: u64) -> String {
|
||||
format!("{v:016x}")
|
||||
}
|
||||
@@ -637,7 +626,6 @@ impl Model {
|
||||
CachedEval {
|
||||
error: None,
|
||||
warning: r.warning.clone(),
|
||||
boolean_diagnostics: r.boolean_diagnostics.clone(),
|
||||
ms: r.ms,
|
||||
bytes: r.state.retained_bytes(),
|
||||
state: r.state.clone(),
|
||||
|
||||
@@ -393,12 +393,12 @@ mod tests {
|
||||
let mut state = crate::ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2., 8.).unwrap(), 1));
|
||||
let mut cache = super::super::PlacedBodies::default();
|
||||
let before = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
let before = super::super::world_state_reusing(&doc, &state, &mut cache).unwrap();
|
||||
let meshes: Vec<_> = before.bodies.iter().map(|b| b.mesh()).collect();
|
||||
// The middle placement moves: the other two are taken as they were, it is placed again.
|
||||
doc.occurrences.iter_mut().find(|o| o.id == second).unwrap().transform = crate::rigid(Vec3::new(0., 40., 0.), Vec3::ZERO, Vec3::X, 0.2);
|
||||
let after = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
let fresh = super::super::world_state_reusing(&doc, &state, &mut super::super::PlacedBodies::default());
|
||||
let after = super::super::world_state_reusing(&doc, &state, &mut cache).unwrap();
|
||||
let fresh = super::super::world_state_reusing(&doc, &state, &mut super::super::PlacedBodies::default()).unwrap();
|
||||
let names = |st: &crate::ModelState| st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&after), names(&fresh));
|
||||
assert_eq!(names(&after), names(&before));
|
||||
|
||||
@@ -97,7 +97,7 @@ const UNIFIED: &[(&str, TappedThreadFamily, f64, u32, f64)] = &[
|
||||
fn metric_size(major_diameter: f64, pitch: f64) -> TappedThread {
|
||||
TappedThread {
|
||||
family: TappedThreadFamily::Metric,
|
||||
designation: crate::eval::format_thread(major_diameter, pitch),
|
||||
designation: crate::thread_forms::ThreadForm::Metric.format(major_diameter, pitch, pitch),
|
||||
major_diameter,
|
||||
pitch,
|
||||
// Preserve the existing metric estimate and rounding used by solid.hole.
|
||||
|
||||
@@ -148,9 +148,6 @@ impl TessPlan {
|
||||
[q.x, q.y, q.z, c[3]]
|
||||
})
|
||||
.collect(),
|
||||
// Which directions the surface joins itself along is a property of its
|
||||
// parameters: moving the surface does not change it.
|
||||
closed: *closed,
|
||||
},
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
// `m` (as rows) after the surface's own affine map.
|
||||
|
||||
@@ -749,14 +749,6 @@ impl Body {
|
||||
self.color
|
||||
}
|
||||
|
||||
/// The same body, with how far its boundaries may lie off their surfaces (mm) as the file it
|
||||
/// was read from declares (see [`Body::boundary_tol`]). A value that is not finite and above
|
||||
/// zero clears the declaration, which puts the body back on the kernel's own bound.
|
||||
pub(crate) fn with_boundary_tol(mut self, tol: Option<f64>) -> Body {
|
||||
self.boundary_tol = tol.filter(|t| t.is_finite() && *t > 0.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// The same body with a display colour.
|
||||
pub fn with_color(mut self, c: Option<[f32; 3]>) -> Body {
|
||||
self.color = c.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0)));
|
||||
@@ -1050,14 +1042,12 @@ impl Body {
|
||||
}
|
||||
let Ok(m) = self.mesh(&MeshTolerance::chord(coarse_tolerance(self.size()))) else { return self.face_count() };
|
||||
let mut has = vec![false; self.face_count()];
|
||||
for f in &plan.faces {
|
||||
if !f.triangles.is_empty()
|
||||
&& let Some(h) = has.get_mut(f.face as usize)
|
||||
{
|
||||
for f in &m.tri_face {
|
||||
if let Some(h) = has.get_mut(*f as usize) {
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
Some(has.iter().filter(|h| !**h).count())
|
||||
has.iter().filter(|h| !**h).count()
|
||||
}
|
||||
|
||||
/// A mesh with its edges flagged for display (seams, tangent joins, protected creases).
|
||||
|
||||
@@ -1082,6 +1082,20 @@ pub(crate) fn loft_numbered_loops(sections: &[(Plane, NumberedLoop)], reversed:
|
||||
// What each section is matched to: the normal the windings share, and the corners' directions
|
||||
// from the centre (the lead's for the first section, then the section before).
|
||||
let (mut normal, mut dirs) = (first_normal, corners(&lead.0, &lead.1.oriented(lead.0.normal().dot(first_normal) < 0.0)).1);
|
||||
// The share of the lead's perimeter before each of its vertices: where a circle section
|
||||
// breaks when the matched directions do not go round in order.
|
||||
let lead_len: f64 = lead.1.lp.segs.iter().map(Seg2::length).sum();
|
||||
let fractions: Vec<f64> = lead
|
||||
.1
|
||||
.lp
|
||||
.segs
|
||||
.iter()
|
||||
.scan(0.0, |before, s| {
|
||||
let f = *before / lead_len;
|
||||
*before += s.length();
|
||||
Some(f)
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(ordered.len());
|
||||
for (p, l) in &ordered {
|
||||
let back = p.normal().dot(normal) < 0.0;
|
||||
@@ -1089,7 +1103,7 @@ pub(crate) fn loft_numbered_loops(sections: &[(Plane, NumberedLoop)], reversed:
|
||||
// Break angles: the matched corners seen in this plane. A re-split circle runs counter-
|
||||
// clockwise in its plane; wound the other way when the plane faces back.
|
||||
let angles: Vec<f64> = local.iter().map(|d| d.angle()).collect();
|
||||
let mut r = l.oriented(back).resample(n, &angles).oriented(back);
|
||||
let mut r = l.oriented(back).resample(n, &angles, &fractions, false).oriented(back);
|
||||
// Start where every vertex lines up best with the matched one of the same number (the
|
||||
// largest sum of dot products of their directions from the centroids): the single vertex
|
||||
// closest in direction twisted non-convex sections (#881).
|
||||
|
||||
@@ -605,5 +605,4 @@ fn a_curve_over_a_tiny_knot_span_still_bounds_its_faces() {
|
||||
fn the_global_sections_native_system_becomes_the_originating_system() {
|
||||
let imp = round_trip(&box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap());
|
||||
assert_eq!(imp.system, "SolidCraft");
|
||||
assert_eq!(imp.preprocessor, "SolidCraft IGES");
|
||||
}
|
||||
|
||||
@@ -150,7 +150,6 @@ mod tspline;
|
||||
mod untouched_faces_tests;
|
||||
mod validity;
|
||||
pub mod watch;
|
||||
mod whole_face;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each, fillet_setback, tangent_chain};
|
||||
pub use blend_corner::fillet_radii;
|
||||
@@ -220,7 +219,6 @@ pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at, m
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
pub use validity::{SmlibCheck, Validity, smlib_built, smlib_dump_dir, validity};
|
||||
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
|
||||
@@ -130,65 +130,9 @@ mod tests {
|
||||
assert!(measure(&ring).unwrap().merged.faces > 0);
|
||||
}
|
||||
|
||||
/// A 10 mm cube at `at`, each side in n × n squares of two triangles, one face per side;
|
||||
/// without its first `open` triangles.
|
||||
fn cube(at: Vec3, n: usize, open: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = 10.0;
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side * s;
|
||||
c[(axis + 1) % 3] = a as f64 * s / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 * s / n as f64;
|
||||
at + Vec3::new(c[0], c[1], c[2])
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
m.tri_face.extend([(2 * axis) as u32 + side as u32; 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.triangles.drain(..open);
|
||||
m.tri_face.drain(..open);
|
||||
m
|
||||
}
|
||||
|
||||
/// The summed per-face sums of `m` about `o`, with the moments about the origin (as
|
||||
/// `measure` passes them on).
|
||||
fn sums(m: &Mesh, o: Vec3) -> (f64, f64, Vec3, Vec3) {
|
||||
let (area, v6, mom, normal) =
|
||||
face_sums(m, 6, o).iter().fold((0.0, 0.0, Vec3::ZERO, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.1, s.2 + f.2, s.3 + f.3));
|
||||
(area, v6, o * (4.0 * v6) + mom, normal)
|
||||
}
|
||||
|
||||
/// A cube 1000 mm from the origin with one 0.5 mm² facet left out: about the box's centre
|
||||
/// the sums miss a cone of that facet to the centre (0.08 %), where about the origin they are
|
||||
/// 17 % over. Closed, it measures the same about either.
|
||||
#[test]
|
||||
fn a_mesh_left_open_is_measured_about_its_centre() {
|
||||
let at = Vec3::new(1000.0, 1000.0, 1000.0);
|
||||
let (closed, open) = (cube(at, 10, 0), cube(at, 10, 1));
|
||||
let centre = at + Vec3::new(5.0, 5.0, 5.0);
|
||||
for o in [Vec3::ZERO, centre] {
|
||||
let (area, v6, mom, normal) = sums(&closed, o);
|
||||
assert!((area - 600.0).abs() < 1e-9 && (v6 / 6.0 - 1000.0).abs() < 1e-6 && normal.len() < 1e-9);
|
||||
assert!((mom / (4.0 * v6) - centre).len() < 1e-6 * at.len(), "{o:?}");
|
||||
}
|
||||
let (_, v6_origin, _, normal) = sums(&open, Vec3::ZERO);
|
||||
assert!((normal.len() - 0.5).abs() < 1e-9);
|
||||
// The facet faces the origin: leaving it out adds its cone to the origin, 167 mm³.
|
||||
assert!((v6_origin / 6.0 - (1000.0 + 0.5 * 1000.0 / 3.0)).abs() < 1e-3, "{}", v6_origin / 6.0);
|
||||
let (_, v6, mom, _) = sums(&open, centre);
|
||||
assert!((v6 / 6.0 - (1000.0 - 0.5 * 5.0 / 3.0)).abs() < 1e-6, "{}", v6 / 6.0);
|
||||
assert!((mom / (4.0 * v6) - centre).len() < 0.01, "{:?}", mom / (4.0 * v6));
|
||||
}
|
||||
|
||||
// A body's measured volume does not depend on where it sits: the same revolved ring of cones
|
||||
// and cylinders, moved far from the origin, measures what it measures at the origin.
|
||||
use crate::{revolve, transform};
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
//! The last resort for a face the planner cannot plan however finely its edges are divided: a
|
||||
//! leftover of the file's own modelling, such as a cusp where two edges leave a point at a zero
|
||||
//! angle, a sliver whose two sides sag across each other, or a small cap round a pole. Its
|
||||
//! boundary is filled flat, so the rest of the body keeps its plan rather than the whole body
|
||||
//! falling back to the reference mesh.
|
||||
//!
|
||||
//! The boundary nodes (the shared edge nodes, so the face still meets its neighbours exactly)
|
||||
//! are laid onto the plane that best fits them and triangulated there (see `cdt::triangulate`):
|
||||
//! every boundary side is kept as a constraint unless it crosses one already kept (the defect
|
||||
//! itself), and a triangle is in the face when it lies inside an odd number of loops. The fill is
|
||||
//! kept only where it stays within the chord tolerance of the surface, so a real curved face is
|
||||
//! left to the body's reference mesh. The triangles turn the way the surface does, and each node
|
||||
//! keeps its parameters on the surface for its normal.
|
||||
|
||||
use solidcraft_geom::tess::{EvalSurface, FacePlan};
|
||||
use solidcraft_geom::{Plane, Vec3};
|
||||
|
||||
use super::{cdt, uv};
|
||||
|
||||
/// Most boundary nodes a face is filled at.
|
||||
const MAX_NODES: usize = 50_000;
|
||||
/// Nodes the surface's normal is sampled at to find which way it faces.
|
||||
const NORMAL_SAMPLES: usize = 16;
|
||||
|
||||
/// The face `fi` of surface `s`, bounded by the loops `loops` (in space), filled flat; `None`
|
||||
/// when its nodes do not lie on the surface within `accept`, span no plane, enclose nothing, or
|
||||
/// the flat fill strays from the surface by more than the chord tolerance `tol` (a real curved
|
||||
/// face, which the body's reference mesh draws better).
|
||||
pub(crate) fn fill(fi: usize, s: &EvalSurface, loops: &[Vec<Vec3>], accept: f64, tol: f64) -> Option<FacePlan> {
|
||||
let fixed: Vec<Vec3> = loops.iter().flatten().copied().collect();
|
||||
if fixed.len() < 3 || fixed.len() > MAX_NODES {
|
||||
return None;
|
||||
}
|
||||
// Each node's parameters, for its normal (a projection from the previous node's).
|
||||
let dom = uv::domain(s);
|
||||
let mut params: Vec<[f64; 2]> = Vec::with_capacity(fixed.len());
|
||||
let mut ranges = Vec::with_capacity(loops.len());
|
||||
for l in loops {
|
||||
let start = params.len();
|
||||
let mut prev = None;
|
||||
for p in l {
|
||||
let q = uv::project(s, &dom, *p, prev, accept)?;
|
||||
params.push(q);
|
||||
prev = Some(q);
|
||||
}
|
||||
ranges.push(start..params.len());
|
||||
}
|
||||
// The plane: the loops' area vector (holes count against the outer loop) through their
|
||||
// centroid.
|
||||
let normal = loops.iter().fold(Vec3::ZERO, |n, l| n + crate::plane_fit::loop_area(l));
|
||||
let centre = fixed.iter().fold(Vec3::ZERO, |c, p| c + *p) * (1.0 / fixed.len() as f64);
|
||||
let plane = Plane::from_normal(centre, normal)?;
|
||||
let flat: Vec<[f64; 2]> = fixed.iter().map(|p| plane.to_local(*p)).map(|q| [q.x, q.y]).collect();
|
||||
let fill = cdt::Fill { seeds: &[], refine: None, shape: false, lenient: true };
|
||||
let (_, mut triangles) = cdt::triangulate(&flat, &ranges, fill)?;
|
||||
if triangles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Flat only where flat is close enough: each triangle's middle within the chord tolerance of
|
||||
// the surface (found from its first corner's parameters).
|
||||
let off = |t: &[u32; 3]| -> Option<f64> {
|
||||
let [a, b, c] = t.map(|i| fixed.get(i as usize).copied());
|
||||
let mid = (a? + b? + c?) * (1.0 / 3.0);
|
||||
let q = uv::project(s, &dom, mid, params.get(t[0] as usize).copied(), f64::INFINITY)?;
|
||||
Some(s.derivs(q[0], q[1])?.0.dist(mid))
|
||||
};
|
||||
if !triangles.iter().all(|t| off(t).is_some_and(|d| d <= tol)) {
|
||||
return None;
|
||||
}
|
||||
// Counter-clockwise about the plane's normal; turned over where the surface faces the other way.
|
||||
let step = (params.len() / NORMAL_SAMPLES).max(1);
|
||||
let along: f64 = params.iter().step_by(step).filter_map(|q| s.derivs(q[0], q[1])).map(|(_, su, sv)| su.cross(sv).dot(plane.normal())).sum();
|
||||
if along < 0.0 {
|
||||
for t in &mut triangles {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Some(FacePlan { face: u32::try_from(fi).unwrap_or(u32::MAX), surface: None, uv: params, fixed, normals: Vec::new(), flip: false, triangles })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A plane face whose right side folds back over itself at a zero angle (a cusp): no side
|
||||
/// crossing another may be kept, yet the face is filled and turns the plane's way.
|
||||
#[test]
|
||||
fn a_cusp_is_filled_the_surfaces_way() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let corners = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [3.0, 4.2], [3.8, 3.6], [0.0, 4.0]];
|
||||
let l: Vec<Vec3> = corners.iter().map(|q| Vec3::new(q[0], q[1], 0.0)).collect();
|
||||
let Some(f) = fill(0, &s, &[l], 1e-9, 1e-9) else { panic!("filled") };
|
||||
assert!(!f.triangles.is_empty());
|
||||
let area: f64 = f
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let [a, b, c] = t.map(|i| f.fixed[i as usize]);
|
||||
(b - a).cross(c - a).z * 0.5
|
||||
})
|
||||
.sum();
|
||||
assert!(area > 14.0 && area < 16.5, "area {area}");
|
||||
assert_eq!(f.uv.len(), f.fixed.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_enclosing_nothing_is_not_filled() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let l = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
|
||||
assert!(fill(0, &s, &[l], 1e-3, 1e-3).is_none());
|
||||
}
|
||||
}
|
||||
@@ -37,41 +37,6 @@ pub(crate) fn winds(loops: &[Loop], d: usize, per: f64) -> bool {
|
||||
loops.iter().any(|l| drift(l, d, per).abs() > per * 0.5)
|
||||
}
|
||||
|
||||
/// A loop through a pole of the closed direction `d` (a cone's apex or a sphere's pole on the
|
||||
/// boundary): the parameter along `d` is free there, so the step into or out of the pole may be
|
||||
/// any length. Unwrapped to the nearest copy at every step, a sector wider than half a turn, or
|
||||
/// a cone face written as its rim, the seam up to the apex and the seam back (as STEP files
|
||||
/// write a pointed end, a screw's tip), seems to wind around the surface, and the face cannot
|
||||
/// be cut along a seam it does not have. The step out of the pole is made the one that closes
|
||||
/// the loop. A loop that closes already, has no pole or passes more than one stays as it is.
|
||||
pub(crate) fn close_through_pole(s: &EvalSurface, l: &mut Loop, d: usize, per: f64) {
|
||||
let w = drift(l, d, per);
|
||||
let n = l.uv.len();
|
||||
if w.abs() <= per * 0.5 || n < 3 || l.pts.len() != n {
|
||||
return;
|
||||
}
|
||||
let pole: Vec<bool> = l.uv.iter().map(|q| super::uv::degenerate(s, *q, d)).collect();
|
||||
let Some(k) = pole.iter().position(|p| *p) else { return };
|
||||
// The node the loop leaves the pole for: the loop is turned to end there, so the step that
|
||||
// closes it is an ordinary one, and the step from the pole takes what is left. A loop
|
||||
// through a pole twice is left alone.
|
||||
let Some(out) = (1..n).map(|i| (k + i) % n).find(|&i| !pole.get(i).copied().unwrap_or(true)) else { return };
|
||||
if pole.iter().enumerate().any(|(i, p)| *p && (i + n - out) % n < (k + n - out) % n) {
|
||||
return;
|
||||
}
|
||||
let start = (out + 1) % n;
|
||||
l.uv.rotate_left(start);
|
||||
l.pts.rotate_left(start);
|
||||
// Unwrapped again from the new start; the last node, where the loop leaves the pole, is
|
||||
// taken next to the first instead of next to the pole.
|
||||
for i in 1..n {
|
||||
let near = if i + 1 == n { l.uv.first() } else { l.uv.get(i - 1) }.map(|q| q[d]);
|
||||
if let (Some(near), Some(q)) = (near, l.uv.get_mut(i)) {
|
||||
q[d] = super::uv::unwrap(q[d], near, Some(per));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point where the boundary crosses a cut line: its place along the line, its 3D point, and
|
||||
/// whether the face lies above it (towards larger parameters across the line) or below.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -135,37 +135,15 @@ mod tests {
|
||||
#[test]
|
||||
fn divides_the_turn_but_not_the_straight_direction() {
|
||||
let s = cylinder();
|
||||
let turn = divide(
|
||||
&s,
|
||||
1,
|
||||
(0.0, std::f64::consts::TAU),
|
||||
(0.0, 1.0),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
f64::INFINITY,
|
||||
10_000,
|
||||
);
|
||||
let along = divide(
|
||||
&s,
|
||||
0,
|
||||
(0.0, 1.0),
|
||||
(0.0, std::f64::consts::TAU),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
f64::INFINITY,
|
||||
10_000,
|
||||
);
|
||||
let turn =
|
||||
divide(&s, 1, (0.0, std::f64::consts::TAU), (0.0, 1.0), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, f64::INFINITY, 10_000);
|
||||
let along =
|
||||
divide(&s, 0, (0.0, 1.0), (0.0, std::f64::consts::TAU), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, f64::INFINITY, 10_000);
|
||||
assert_eq!(along, vec![0.0, 1.0]);
|
||||
// Chord sag r(1 − cos(θ/2)) ≤ 0.01 with r = 3 needs θ ≤ 0.163: at least 39 segments.
|
||||
assert!(turn.len() >= 40 && turn.len() < 80, "{}", turn.len());
|
||||
// With a longest side the straight direction is divided too: 4 mm in sides ≤ 0.5.
|
||||
let along = divide(
|
||||
&s,
|
||||
0,
|
||||
(0.0, 1.0),
|
||||
(0.0, std::f64::consts::TAU),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
0.5,
|
||||
10_000,
|
||||
);
|
||||
let along = divide(&s, 0, (0.0, 1.0), (0.0, std::f64::consts::TAU), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, 0.5, 10_000);
|
||||
assert_eq!(along.len(), 9);
|
||||
}
|
||||
|
||||
@@ -175,15 +153,7 @@ mod tests {
|
||||
#[test]
|
||||
fn divides_with_the_ends_exactly() {
|
||||
let (a0, a1) = (-1.010485317689877, 1.083909784703316);
|
||||
let lines = divide(
|
||||
&cylinder(),
|
||||
1,
|
||||
(a0, a1),
|
||||
(0.0, 1.0),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
f64::INFINITY,
|
||||
10_000,
|
||||
);
|
||||
let lines = divide(&cylinder(), 1, (a0, a1), (0.0, 1.0), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, f64::INFINITY, 10_000);
|
||||
assert_eq!(lines.first().copied(), Some(a0));
|
||||
assert_eq!(lines.last().copied(), Some(a1));
|
||||
}
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
//! Extrudes from a whole planar face of a body (#2040). The profile of a face is its outline
|
||||
//! projected into a sketch, and an edge that is no line or arc comes in fitted: the elliptic
|
||||
//! arcs where a fillet meets a slanted face are cubic splines a hair off the body's edges. A
|
||||
//! join's tool then touches the body along a face it does not match, and a cut's walls lie a
|
||||
//! hair inside or outside the body all round: the boolean finds pieces of no area and gives up.
|
||||
//!
|
||||
//! When the profile is the face's outline (to within the fit), the face itself is used: a join
|
||||
//! sweeps the body's own face, which shares its edges with the body and is sewn on along it, and
|
||||
//! a cut whose walls take nothing from the body (all of it within the cut's depth lies over the
|
||||
//! face) is the body cut by a slab beyond the depth plane, which meets it only across that plane.
|
||||
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, v3};
|
||||
use crate::history::{Change, History};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Most boundary samples compared per outline.
|
||||
const MAX_SAMPLES: usize = 20_000;
|
||||
|
||||
/// A planar face of `target` and where it lies.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WholeFace {
|
||||
/// The face's index in `target`.
|
||||
pub face: usize,
|
||||
/// Its outward normal.
|
||||
pub normal: Vec3,
|
||||
/// Its height along the sketch normal, from the sketch plane's origin.
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// How far a fitted outline may lie from the face's edges: the projection fits a smooth chain to
|
||||
/// within 1e-4 of its extent (`doc::project`), and the mesh the edges are sampled from sags.
|
||||
fn outline_tol(target: &Body) -> f64 {
|
||||
(target.size() * 5e-4).max(1e-4)
|
||||
}
|
||||
|
||||
/// The planar face of `target` at one of `heights` along `plane`'s normal (from its origin)
|
||||
/// whose outline is `region`'s outer loop, to within the projection's fit, with no holes on
|
||||
/// either side.
|
||||
pub fn matching_face(target: &Body, plane: &Plane, region: &Region2, heights: &[f64]) -> Option<WholeFace> {
|
||||
if !region.holes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = plane.normal();
|
||||
let tol = outline_tol(target);
|
||||
let mesh = target.tessellate(tol * 0.2).ok()?;
|
||||
let ring: Vec<Vec2> = region.outer.polyline(tol * 0.1).into_vec();
|
||||
if ring.len() < 3 || ring.len() > MAX_SAMPLES {
|
||||
return None;
|
||||
}
|
||||
let faces: Vec<&mt::Face> = target.solid.face_iter().collect();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let Some(m) = crate::blend::plane_normal(f) else { continue };
|
||||
if m.dot(n).abs() < 1.0 - 1e-9 || f.boundaries().len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let edges = face_outline(&mesh, i);
|
||||
let Some(height) = edges.first().and_then(|e| e.first()).map(|p| (*p - plane.origin).dot(n)) else { continue };
|
||||
if !heights.iter().any(|h| (h - height).abs() <= tol) {
|
||||
continue;
|
||||
}
|
||||
// The outline in the sketch's plane, at the face's height.
|
||||
let at = |p: Vec2| plane.to_world(p) + n * height;
|
||||
let ring3: Vec<Vec3> = ring.iter().map(|p| at(*p)).collect();
|
||||
let segs: Vec<(Vec3, Vec3)> = edges.iter().flat_map(|e| e.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?)))).collect();
|
||||
if segs.is_empty() || segs.len() > MAX_SAMPLES {
|
||||
continue;
|
||||
}
|
||||
let near_edges = |p: Vec3| segs.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol);
|
||||
let ring_segs: Vec<(Vec3, Vec3)> = (0..ring3.len()).filter_map(|k| Some((*ring3.get(k)?, *ring3.get((k + 1) % ring3.len())?))).collect();
|
||||
let near_ring = |p: Vec3| ring_segs.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol);
|
||||
let off_plane = segs.iter().any(|(a, _)| ((*a - plane.origin).dot(n) - height).abs() > tol);
|
||||
if !off_plane && ring3.iter().all(|p| near_edges(*p)) && segs.iter().all(|(a, _)| near_ring(*a)) {
|
||||
return Some(WholeFace { face: i, normal: m, height });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The boundary polylines of face `face` of `mesh`, seams left out.
|
||||
fn face_outline(mesh: &Mesh, face: usize) -> Vec<Vec<Vec3>> {
|
||||
let Ok(f) = u32::try_from(face) else { return Vec::new() };
|
||||
mesh.face_edges(f).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).filter_map(|e| mesh.edges.get(e).cloned()).collect()
|
||||
}
|
||||
|
||||
/// The prism `built` (made from a profile that [`matching_face`] matched to `face`) made again
|
||||
/// from the face itself: the face swept `length` along its outward normal, `outward` or back
|
||||
/// into the body. Each of its faces comes from the face of `built` it lies on, so the feature
|
||||
/// names its faces as it named `built`'s.
|
||||
pub fn face_prism(target: &Body, face: WholeFace, length: f64, outward: bool, built: &Body) -> Result<Body> {
|
||||
if !length.is_finite() || length <= 0.0 {
|
||||
return Err(KernelError::Invalid("a prism from a face needs a positive length".into()));
|
||||
}
|
||||
let f = target.solid.face_iter().nth(face.face).cloned().ok_or_else(|| KernelError::Invalid("no such face".into()))?;
|
||||
let along = if outward { face.normal * length } else { face.normal * -length };
|
||||
let swept = crate::guard("prism from a face", || Body::new(mt::builder::tsweep(&f, v3(along))))?;
|
||||
traced_onto(swept, built, outline_tol(target))
|
||||
}
|
||||
|
||||
/// `swept`, made in place of `built`, with each of its faces coming from the face of `built`
|
||||
/// nearest it (so a feature names its faces as it named `built`'s).
|
||||
fn traced_onto(swept: Body, built: &Body, tol: f64) -> Result<Body> {
|
||||
let (mine, theirs) = (swept.tessellate(tol)?, built.tessellate(tol)?);
|
||||
let mut faces = Vec::with_capacity(swept.face_count());
|
||||
for i in 0..swept.face_count() {
|
||||
let probe = face_probe(&mine, i).ok_or_else(|| KernelError::Failed("tool from a face: a face with no mesh".into()))?;
|
||||
let from = nearest_face(&theirs, probe).ok_or_else(|| KernelError::Failed("tool from a face: no face under it".into()))?;
|
||||
faces.push(crate::record::from_face(0, from, Change::Modified));
|
||||
}
|
||||
let history: History = crate::record::from_faces(&swept.solid, 1, faces);
|
||||
swept.with_history_counts(history, &crate::record::counts(&[built]))
|
||||
}
|
||||
|
||||
/// The centroid of face `face`'s largest triangle.
|
||||
fn face_probe(mesh: &Mesh, face: usize) -> Option<Vec3> {
|
||||
let f = u32::try_from(face).ok()?;
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, tf)| **tf == f)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|x, y| (x[1] - x[0]).cross(x[2] - x[0]).len().total_cmp(&(y[1] - y[0]).cross(y[2] - y[0]).len()))
|
||||
.map(|[a, b, c]| (a + b + c) / 3.0)
|
||||
}
|
||||
|
||||
/// The face of `mesh` nearest `p`.
|
||||
fn nearest_face(mesh: &Mesh, p: Vec3) -> Option<usize> {
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter_map(|(t, f)| mesh.tri(t).map(|[a, b, c]| (crate::polyhedron::point_tri(p, a, b, c), *f)))
|
||||
.min_by(|x, y| x.0.total_cmp(&y.0))
|
||||
.and_then(|(_, f)| usize::try_from(f).ok())
|
||||
}
|
||||
|
||||
/// Whether a cut `depth` into `target` from `face` takes only what lies over the face: nothing of
|
||||
/// the body stands beyond the face's plane, and everything of it less than `depth` below lies
|
||||
/// over the face (on its outline at most). Then the prism over the face and a slab over the whole
|
||||
/// body take the same material, and the slab meets the body only across its depth plane.
|
||||
pub fn cut_takes_only_the_face(target: &Body, face: WholeFace, depth: f64) -> bool {
|
||||
if !depth.is_finite() || depth <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let tol = outline_tol(target);
|
||||
let Ok(mesh) = target.tessellate(tol * 0.2) else { return false };
|
||||
let Some(origin) = face_outline(&mesh, face.face).first().and_then(|e| e.first().copied()) else { return false };
|
||||
let n = face.normal;
|
||||
// The face's plane with the outline in it, flattened.
|
||||
let Some(x) = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized() else { return false };
|
||||
let y = n.cross(x);
|
||||
let flat = |p: Vec3| Vec2::new((p - origin).dot(x), (p - origin).dot(y));
|
||||
let segs: Vec<(Vec2, Vec2)> = face_outline(&mesh, face.face)
|
||||
.iter()
|
||||
.flat_map(|e| e.windows(2).filter_map(|w| Some((flat(*w.first()?), flat(*w.get(1)?)))).collect::<Vec<_>>())
|
||||
.collect();
|
||||
if segs.len() < 3 || segs.len() > MAX_SAMPLES {
|
||||
return false;
|
||||
}
|
||||
let mut points = mesh.positions.clone();
|
||||
points.extend(mesh.triangles.iter().filter_map(|t| mesh.tri(t)).map(|[a, b, c]| (a + b + c) / 3.0));
|
||||
points.iter().all(|p| {
|
||||
let h = (*p - origin).dot(n);
|
||||
h <= tol && (h <= -depth + tol || over(&segs, flat(*p), tol))
|
||||
})
|
||||
}
|
||||
|
||||
/// How a wall swept into the body from an edge of the face meets the body along that edge, from
|
||||
/// the face beside it (outward normal `n_N`) and the sweep's direction `−n_P`: `n_N · (−n_P)`
|
||||
/// above zero leaves the body (the wall only touches it along the edge), zero lies in the face
|
||||
/// beside it, below zero goes into the material (a real cut).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Wall {
|
||||
Touches,
|
||||
Cuts,
|
||||
}
|
||||
|
||||
/// The walls of each edge of face `face` of `mesh` (by mesh edge), `None` where an edge's wall
|
||||
/// changes from one to the other along it.
|
||||
fn walls(mesh: &Mesh, face: usize, n: Vec3, tol: f64) -> Option<Vec<(usize, Wall)>> {
|
||||
let f = u32::try_from(face).ok()?;
|
||||
let mut out = Vec::new();
|
||||
for e in mesh.face_edges(f) {
|
||||
if mesh.seams.get(e).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let other = mesh.edge_faces.get(e)?.iter().copied().find(|g| *g != f)?;
|
||||
let pts = mesh.edges.get(e)?;
|
||||
let tris: Vec<[Vec3; 3]> = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g == other).filter_map(|(t, _)| mesh.tri(t)).collect();
|
||||
let dist = |p: Vec3, t: &[Vec3; 3]| crate::polyhedron::point_tri(p, t[0], t[1], t[2]);
|
||||
// Each point's nearest triangle of the face beside, by its normal.
|
||||
let mut signs = pts.iter().filter_map(|p| {
|
||||
let [a, b, c] = *tris.iter().min_by(|x, y| dist(*p, x).total_cmp(&dist(*p, y)))?;
|
||||
let m = (b - a).cross(c - a).normalized()?;
|
||||
Some(if -m.dot(n) >= -tol { Wall::Touches } else { Wall::Cuts })
|
||||
});
|
||||
let first = signs.next()?;
|
||||
if signs.any(|s| s != first) {
|
||||
return None;
|
||||
}
|
||||
out.push((e, first));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether `q` lies inside the outline made of `segs` (in any order and direction, by even-odd
|
||||
/// crossings) or within `tol` of it.
|
||||
fn over(segs: &[(Vec2, Vec2)], q: Vec2, tol: f64) -> bool {
|
||||
let on = segs.iter().any(|(a, b)| {
|
||||
let ab = *b - *a;
|
||||
let t = if ab.len2() > 0.0 { ((q - *a).dot(ab) / ab.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
q.dist(*a + ab * t) <= tol
|
||||
});
|
||||
on || segs.iter().filter(|(a, b)| (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x)).count() % 2 == 1
|
||||
}
|
||||
|
||||
/// Even-odd containment of `q` in the closed polygon `poly`.
|
||||
fn inside(poly: &[Vec2], q: Vec2) -> bool {
|
||||
let m = poly.len();
|
||||
(0..m)
|
||||
.filter(|i| match (poly.get(*i), poly.get((i + 1) % m)) {
|
||||
(Some(a), Some(b)) => (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x),
|
||||
_ => false,
|
||||
})
|
||||
.count()
|
||||
% 2
|
||||
== 1
|
||||
}
|
||||
|
||||
/// A tool cutting `depth` into `target` from its planar face `face`, as the face swept would, with
|
||||
/// the walls that only touch the body moved off it: the face's outline keeps its edges whose walls
|
||||
/// cut into the body, exactly (the body's own edges), and runs round the others well outside it.
|
||||
/// The tool starts a little out from the face, so its cap does not lie on it. `None` when no wall
|
||||
/// cuts (the face's prism takes only what is over it, see [`cut_takes_only_the_face`]), when an
|
||||
/// edge's wall changes from touching to cutting along it, or when anything of the body within the
|
||||
/// depth lies where the outline runs round. Its faces come from those of `built` (the profile's
|
||||
/// own tool) they lie nearest, when given. With how far out from the face it starts.
|
||||
pub fn cut_tool(target: &Body, face: WholeFace, depth: f64, built: Option<&Body>) -> Option<(Body, f64)> {
|
||||
// Every step reads or builds truck's shapes, so all of it runs under the guard.
|
||||
crate::guard("cut tool from a face", || Ok(build_cut_tool(target, face, depth, built))).ok().flatten()
|
||||
}
|
||||
|
||||
fn build_cut_tool(target: &Body, face: WholeFace, depth: f64, built: Option<&Body>) -> Option<(Body, f64)> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
use std::f64::consts::{PI, TAU};
|
||||
if !depth.is_finite() || depth <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let size = target.size();
|
||||
let tol = outline_tol(target);
|
||||
let mesh = target.tessellate(tol * 0.2).ok()?;
|
||||
let n = face.normal;
|
||||
let walls = walls(&mesh, face.face, n, 1e-6)?;
|
||||
if !walls.iter().any(|(_, w)| *w == Wall::Cuts) || !walls.iter().any(|(_, w)| *w == Wall::Touches) {
|
||||
return None;
|
||||
}
|
||||
let f = target.solid.face_iter().nth(face.face)?.clone();
|
||||
let wire = f.absolute_boundaries().first()?.clone();
|
||||
// Each edge of the face's outline (in order) with its wall, matched to the mesh's edges by
|
||||
// their middles.
|
||||
let mid = |e: &mt::Edge| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
crate::body::from_p3(c.subs((t0 + t1) * 0.5))
|
||||
};
|
||||
let wall_of = |e: &mt::Edge| {
|
||||
let m = mid(e);
|
||||
walls
|
||||
.iter()
|
||||
.filter_map(|(i, w)| {
|
||||
let pts = mesh.edges.get(*i)?;
|
||||
Some((pts.windows(2).filter_map(|s| Some(m.dist_to_segment(*s.first()?, *s.get(1)?))).fold(f64::INFINITY, f64::min), *w))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.filter(|(d, _)| *d <= tol)
|
||||
.map(|(_, w)| w)
|
||||
};
|
||||
let edges: Vec<(mt::Edge, Wall)> = wire.edge_iter().map(|e| wall_of(e).map(|w| (e.clone(), w))).collect::<Option<_>>()?;
|
||||
// Start at a cutting edge that follows a touching one.
|
||||
let k = edges.len();
|
||||
let start =
|
||||
(0..k).find(|i| edges.get(*i).is_some_and(|e| e.1 == Wall::Cuts) && edges.get((i + k - 1) % k).is_some_and(|e| e.1 == Wall::Touches))?;
|
||||
let edges: Vec<(mt::Edge, Wall)> = edges.iter().cycle().skip(start).take(k).cloned().collect();
|
||||
// The plane's frame, round the face's middle.
|
||||
let origin = face_probe(&mesh, face.face)?;
|
||||
let x = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized()?;
|
||||
let y = n.cross(x);
|
||||
let flat = |p: Vec3| Vec2::new((p - origin).dot(x), (p - origin).dot(y));
|
||||
let lift = |q: Vec2| origin + x * q.x + y * q.y;
|
||||
let outline: Vec<(Vec2, Vec2)> = face_outline(&mesh, face.face)
|
||||
.iter()
|
||||
.flat_map(|e| e.windows(2).filter_map(|w| Some((flat(*w.first()?), flat(*w.get(1)?)))).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let centre = outline.iter().fold(Vec2::ZERO, |a, (p, _)| a + *p) / outline.len().max(1) as f64;
|
||||
let far = size * 2.0;
|
||||
let at = |v: &mt::Vertex| flat(crate::body::from_p3(v.point()));
|
||||
// Which way the outline runs round its middle.
|
||||
let turn: f64 = edges.iter().map(|(e, _)| (at(e.front()) - centre).cross(at(e.back()) - centre)).sum::<f64>().signum();
|
||||
let mut out: Vec<mt::Edge> = Vec::new();
|
||||
let mut detours: Vec<Vec<Vec2>> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some((e, w)) = edges.get(i) {
|
||||
if *w == Wall::Cuts {
|
||||
out.push(e.clone());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// A run of touching edges from vertex `a` to vertex `b`: round it, far out.
|
||||
let a = e.front().clone();
|
||||
let mut j = i;
|
||||
while edges.get(j).is_some_and(|e| e.1 == Wall::Touches) {
|
||||
j += 1;
|
||||
}
|
||||
let b = edges.get(j - 1)?.0.back().clone();
|
||||
let (pa, pb) = (at(&a), at(&b));
|
||||
let (da, db) = ((pa - centre).normalized()?, (pb - centre).normalized()?);
|
||||
let (t0, mut t1) = (da.y.atan2(da.x), db.y.atan2(db.x));
|
||||
// Round the same way as the run, which goes from a to b with the outline.
|
||||
while (t1 - t0) * turn <= 0.0 {
|
||||
t1 += TAU * turn;
|
||||
}
|
||||
let steps = (((t1 - t0).abs() / (PI / 8.0)).ceil() as usize).clamp(2, 64);
|
||||
// A polygon round a circle `far` out (its corners 1/cos(π/16) further), clear of it.
|
||||
let r = far / (PI / 16.0).cos();
|
||||
let mut path = vec![pa];
|
||||
path.extend((0..=steps).map(|s| {
|
||||
let t = t0 + (t1 - t0) * s as f64 / steps as f64;
|
||||
centre + Vec2::new(t.cos(), t.sin()) * if s == 0 || s == steps { far } else { r }
|
||||
}));
|
||||
path.push(pb);
|
||||
let mut verts = vec![a];
|
||||
verts.extend(path.iter().skip(1).take(path.len().saturating_sub(2)).map(|q| mt::builder::vertex(crate::body::p3(lift(*q)))));
|
||||
verts.push(b);
|
||||
out.extend(verts.windows(2).filter_map(|v| Some(mt::builder::line(v.first()?, v.get(1)?))));
|
||||
detours.push(path);
|
||||
i = j;
|
||||
}
|
||||
// Nothing of the body within the depth may lie where the outline runs round.
|
||||
let clear = mesh.positions.iter().all(|p| {
|
||||
let h = (*p - origin).dot(n);
|
||||
let q = flat(*p);
|
||||
h >= tol || h <= -depth + tol || !detours.iter().any(|d| inside(d, q)) || over(&outline, q, tol)
|
||||
});
|
||||
if !clear {
|
||||
return None;
|
||||
}
|
||||
let mut base = mt::Face::try_new(vec![mt::Wire::from(out)], f.surface()).ok()?;
|
||||
if !f.orientation() {
|
||||
base.invert();
|
||||
}
|
||||
// Out from the face, then swept back through it and the depth.
|
||||
let off = (size * 1e-2).max(tol * 10.0);
|
||||
let base = mt::builder::translated(&base, v3(n * off));
|
||||
let tool = Body::new(mt::builder::tsweep(&base, v3(n * -(off + depth)))).ok()?;
|
||||
match built {
|
||||
Some(built) => traced_onto(tool, built, tol).ok().map(|t| (t, off)),
|
||||
None => Some((tool, off)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "whole_face_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,110 +0,0 @@
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use super::*;
|
||||
use crate::{BoolOp, boolean, box_solid, measure};
|
||||
|
||||
fn square(a: Vec2, b: Vec2) -> Region2 {
|
||||
let p = Vec2::new;
|
||||
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
|
||||
Region2 { outer: Loop2 { segs: vec![line(a, p(b.x, a.y)), line(p(b.x, a.y), b), line(b, p(a.x, b.y)), line(p(a.x, b.y), a)] }, holes: vec![] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_matches_the_face_it_outlines_and_no_other() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap();
|
||||
let top = Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY };
|
||||
let whole = matching_face(&block, &top, &square(Vec2::new(0.0, 0.0), Vec2::new(20.0, 10.0)), &[0.0]).unwrap();
|
||||
assert!((whole.normal - Vec3::Z).len() < 1e-9 && whole.height.abs() < 1e-9, "{whole:?}");
|
||||
// A smaller rectangle on the same face is not the face.
|
||||
assert!(matching_face(&block, &top, &square(Vec2::new(1.0, 1.0), Vec2::new(19.0, 9.0)), &[0.0]).is_none());
|
||||
// Nor is the bottom face, though its outline is the same.
|
||||
assert!(matching_face(&block, &top, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[-3.0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_over_the_whole_top_takes_only_the_face_and_a_step_beside_it_does_not() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap();
|
||||
let top =
|
||||
matching_face(&block, &Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[0.0]).unwrap();
|
||||
assert!(cut_takes_only_the_face(&block, top, 2.0));
|
||||
// A step standing beside the top face: a slab would take it too.
|
||||
let step = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 8.0)).unwrap();
|
||||
let stepped = boolean(&block, &step, BoolOp::Union).unwrap().unwrap();
|
||||
let face = matching_face(&stepped, &Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[0.0])
|
||||
.unwrap();
|
||||
assert!(!cut_takes_only_the_face(&stepped, face, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prism_from_the_face_joins_by_its_shared_edges() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap();
|
||||
let plane = Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY };
|
||||
let region = square(Vec2::ZERO, Vec2::new(20.0, 10.0));
|
||||
let face = matching_face(&block, &plane, ®ion, &[0.0]).unwrap();
|
||||
let built = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 3.0).unwrap().remove(0);
|
||||
let tool = face_prism(&block, face, 3.0, true, &built).unwrap();
|
||||
assert!(tool.history().is_some_and(|h| h.operands == 1));
|
||||
let joined = boolean(&block, &tool, BoolOp::Union).unwrap().unwrap();
|
||||
assert!((measure(&joined).unwrap().volume - 20.0 * 10.0 * 8.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The block of #2040: one side at 45°, its top (and, with `bottom`, its bottom) rounded 3.8 mm
|
||||
/// at both ends.
|
||||
fn rounded_block(bottom: bool) -> Body {
|
||||
let p = Vec2::new;
|
||||
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
|
||||
let h = 8.485281374238571;
|
||||
let segs = vec![
|
||||
line(p(0.0, 0.0), p(11.514718625761429, 0.0)),
|
||||
line(p(11.514718625761429, 0.0), p(20.0, h)),
|
||||
line(p(20.0, h), p(0.0, h)),
|
||||
line(p(0.0, h), p(0.0, 0.0)),
|
||||
];
|
||||
let plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
let block = crate::extrude(&plane, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 10.0).unwrap().remove(0);
|
||||
let mut edges = vec![Vec3::new(10.0, -10.0, h), Vec3::new(10.0, 0.0, h)];
|
||||
if bottom {
|
||||
edges.extend([Vec3::new(5.757359312880714, 0.0, 0.0), Vec3::new(5.757359312880714, -10.0, 0.0)]);
|
||||
}
|
||||
crate::fillet(&block, &edges, 3.8).unwrap()
|
||||
}
|
||||
|
||||
fn slanted_face(b: &Body) -> WholeFace {
|
||||
let n = Vec3::new(1.0, 0.0, -1.0).normalized().unwrap();
|
||||
let face = b.solid.face_iter().position(|f| crate::blend::plane_normal(f).is_some_and(|m| m.dot(n) > 1.0 - 1e-9)).unwrap();
|
||||
WholeFace { face, normal: n, height: 0.0 }
|
||||
}
|
||||
|
||||
/// Into the slanted face, the walls from its top edge and the rounds beside it leave the body
|
||||
/// (n_N · −n_P = +0.707 down to 0), those from the end faces lie in them (0), and those from the
|
||||
/// bottom edge and its rounds go into it (−0.707 up to 0): the bottom ones are kept, the others
|
||||
/// moved off.
|
||||
#[test]
|
||||
fn the_walls_into_a_slanted_face_touch_at_the_top_and_cut_at_the_bottom() {
|
||||
let b = rounded_block(true);
|
||||
let face = slanted_face(&b);
|
||||
let mesh = b.tessellate(outline_tol(&b) * 0.2).unwrap();
|
||||
let walls = walls(&mesh, face.face, face.normal, 1e-6).unwrap();
|
||||
assert_eq!(walls.iter().filter(|(_, w)| *w == Wall::Cuts).count(), 3, "{walls:?}");
|
||||
assert_eq!(walls.iter().filter(|(_, w)| *w == Wall::Touches).count(), 5, "{walls:?}");
|
||||
assert!(!cut_takes_only_the_face(&b, face, 3.0));
|
||||
assert!(cut_tool(&b, face, 3.0, None).is_some());
|
||||
}
|
||||
|
||||
/// A cut 3 mm into the slanted face of the block rounded along its top: the walls from the top
|
||||
/// are moved off it, the one from the bottom edge kept. What it takes: in the face's plane, the
|
||||
/// body's section at depth t is the block's section (10 wide, h high, its top corners rounded)
|
||||
/// moved down the face by t, so the cut takes √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
|
||||
#[test]
|
||||
fn a_cut_into_a_slanted_face_beside_rounds_takes_what_lies_over_it() {
|
||||
let b = rounded_block(false);
|
||||
let face = slanted_face(&b);
|
||||
let (tool, _) = cut_tool(&b, face, 3.0, None).unwrap();
|
||||
let cut = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
|
||||
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
|
||||
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
|
||||
let v = measure(&b).unwrap().volume - measure(&cut).unwrap().volume;
|
||||
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
|
||||
}
|
||||
Reference in New Issue
Block a user