Fix artificial Shell wall splits on stepped bodies
This commit is contained in:
1 parent
13aac4296d
commit
c53aff7592
12 files changed
+379
-12
No files matched your search
@@ -304,5 +304,11 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
@@ -457,3 +457,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("public stepped tab case");
|
||||
doc.set_param("length", "40", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
for (name, kind) in [
|
||||
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
|
||||
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
|
||||
] {
|
||||
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
|
||||
}
|
||||
doc.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let tool = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(
|
||||
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
|
||||
)
|
||||
.unwrap(),
|
||||
Some("Pocket tool"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
|
||||
let cut = doc
|
||||
.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
|
||||
Some("Pocket"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
|
||||
let before = model.state_before(cut);
|
||||
// A real per-face paint control preserves the former slab construction;
|
||||
// no historical names or geometry are fabricated.
|
||||
let old_stock = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(cut).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let at = Vec3::new(0., 15., 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
|
||||
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
|
||||
let mut old_state = crate::ModelState::default();
|
||||
old_state.bodies.push(old_body);
|
||||
let plane = solvecraft_geom::Plane::YZ;
|
||||
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
|
||||
let current_state = model.state();
|
||||
let current = current_state.body("Case").unwrap();
|
||||
let stem = crate::naming::strip_piece(&former);
|
||||
let current_names = crate::naming::face_names(current);
|
||||
let merged = current_names.iter().position(|name| name == stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
|
||||
assert!(!current_names.contains(&former));
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
|
||||
assert_eq!(captured.len(), 4);
|
||||
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
|
||||
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
|
||||
let material = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Saved band stock"),
|
||||
)
|
||||
.unwrap();
|
||||
for (length, wall) in [(40., 2.), (50., 3.)] {
|
||||
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&reopened);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none(), "{result:?}");
|
||||
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
|
||||
let state = current.state();
|
||||
let saved = state.sketch(sk).unwrap();
|
||||
assert!(saved.sketch.links[0].lost);
|
||||
assert_eq!(saved.sketch.points, original.points);
|
||||
assert_eq!(saved.sketch.curves, original.curves);
|
||||
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
|
||||
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
|
||||
solvecraft_kernel::measure(&stock.body).unwrap().volume,
|
||||
stock.mesh().bounds(),
|
||||
original.points
|
||||
);
|
||||
let case = state.body("Case").unwrap();
|
||||
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
|
||||
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ pub struct Body {
|
||||
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
|
||||
/// Exact construction/import source identities, in this body's face order.
|
||||
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
|
||||
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
|
||||
pub(crate) regularization_protected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -360,6 +362,7 @@ impl Body {
|
||||
&& self.paint == other.paint
|
||||
&& self.splits == other.splits
|
||||
&& self.face_sources == other.face_sources
|
||||
&& self.regularization_protected == other.regularization_protected
|
||||
}
|
||||
|
||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||
@@ -398,9 +401,15 @@ impl Body {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
@@ -628,6 +637,7 @@ impl Body {
|
||||
/// The same body with the lines its faces were split along on purpose.
|
||||
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
|
||||
self.splits = s.filter(|l| !l.is_empty());
|
||||
self.regularization_protected |= self.splits.is_some();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -652,6 +662,7 @@ impl Body {
|
||||
})
|
||||
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
|
||||
.map(std::sync::Arc::new);
|
||||
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1063,7 +1074,7 @@ impl Body {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
|
||||
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1077,3 +1088,7 @@ mod lump_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "refine_cache_tests.rs"]
|
||||
mod refine_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "regularization_protection_tests.rs"]
|
||||
mod regularization_protection_tests;
|
||||
@@ -109,6 +109,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,6 +202,7 @@ impl Body {
|
||||
paint: self.paint.clone(),
|
||||
splits: self.splits.clone(),
|
||||
face_sources: None,
|
||||
regularization_protected: self.regularization_protected,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
)),
|
||||
};
|
||||
}
|
||||
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
|
||||
if matches!(op, BoolOp::Cut | BoolOp::Union) {
|
||||
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
|
||||
}
|
||||
@@ -538,7 +539,11 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||||
_ => {
|
||||
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
|
||||
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
|
||||
Body::new(solid).map(Some)
|
||||
let protected = pieces.iter().any(|piece| piece.regularization_protected);
|
||||
Body::new(solid).map(|mut body| {
|
||||
body.regularization_protected = protected;
|
||||
Some(body)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,8 +563,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
);
|
||||
// An affine placement retains face order and appearance metadata.
|
||||
let sources = body.transformed_sources(m)?;
|
||||
Body::new(placed_topology(&body.solid, mat)?)
|
||||
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
|
||||
Body::new(placed_topology(&body.solid, mat)?).map(|placed| {
|
||||
placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,36 @@ fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
|
||||
self.regularize_planar_faces(false)
|
||||
}
|
||||
|
||||
/// Only newly produced Cut walls call this path. The temporary planes carry
|
||||
/// construction support, never persistent ancestor/source-role authority.
|
||||
pub(crate) fn regularize_generated_planar_faces(self) -> Result<Self> {
|
||||
let body = self.regularize_planar_faces(true)?;
|
||||
let sources = body.face_sources().map(|sources| {
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let mut source = source.clone();
|
||||
if let Some(super::FaceSource::ExtrudeSide { plane, coalesced, .. }) = &mut source {
|
||||
if plane.is_some() && !*coalesced {
|
||||
source = None;
|
||||
} else {
|
||||
*plane = None;
|
||||
}
|
||||
}
|
||||
source
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(body.with_face_sources(sources.map(Arc::new)))
|
||||
}
|
||||
|
||||
fn regularize_planar_faces(self, generated: bool) -> Result<Self> {
|
||||
if self.regularization_protected {
|
||||
return Ok(self);
|
||||
}
|
||||
let Some(sources) = self.face_sources() else { return Ok(self) };
|
||||
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
|
||||
return Ok(self);
|
||||
@@ -148,7 +178,16 @@ impl Body {
|
||||
.zip(sources)
|
||||
.map(|(f, s)| {
|
||||
s.as_ref()
|
||||
.and_then(super::planar_sources::certified_plane)
|
||||
.and_then(|source| {
|
||||
if generated {
|
||||
match source {
|
||||
super::FaceSource::ExtrudeSide { segment: solvecraft_geom::Seg2::Line { .. }, plane: Some(p), .. } => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
super::planar_sources::certified_plane(source)
|
||||
}
|
||||
})
|
||||
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
|
||||
})
|
||||
.collect();
|
||||
@@ -179,7 +218,7 @@ impl Body {
|
||||
};
|
||||
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
|
||||
identity = left;
|
||||
if sa != sb {
|
||||
if !generated && sa != sb {
|
||||
continue;
|
||||
}
|
||||
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
|
||||
@@ -235,7 +274,14 @@ impl Body {
|
||||
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
|
||||
metadata = left;
|
||||
}
|
||||
output_sources.push(source.cloned());
|
||||
let mut source = source.cloned();
|
||||
if generated
|
||||
&& replacements.contains_key(&leader)
|
||||
&& let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source
|
||||
{
|
||||
*coalesced = true;
|
||||
}
|
||||
output_sources.push(source);
|
||||
offset += 1;
|
||||
}
|
||||
shells.push(mt::Shell::from(output));
|
||||
|
||||
@@ -114,13 +114,15 @@ impl Body {
|
||||
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
|
||||
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
|
||||
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
|
||||
let self_ = self.inherit_regularization_protection(input).inherit_regularization_protection(tool);
|
||||
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
|
||||
return Ok(self);
|
||||
return Ok(self_);
|
||||
}
|
||||
self.map_planar_sources(input, None, Some(tool), &[], constructed)
|
||||
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
|
||||
}
|
||||
|
||||
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
let Some(sources) = input.face_sources() else { return Ok(self) };
|
||||
if input.solid.face_iter().count() > 16_384
|
||||
|| self.solid.face_iter().count() > 16_384
|
||||
|
||||
@@ -342,7 +342,15 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
|
||||
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
|
||||
/// gives the cavity; the shell is the body minus the cavity.
|
||||
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
|
||||
// Face paint and authored splits are construction boundaries. Retain their
|
||||
// eligibility veto through the temporary body used for the cavity Cut.
|
||||
// Paint indices stay in source order by avoiding healing in that case.
|
||||
let solid = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) {
|
||||
b.deep_copy()
|
||||
} else {
|
||||
crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep())
|
||||
};
|
||||
let healed = Body::new(solid)?.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b);
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
// Faces to open: the face of the triangle nearest each point.
|
||||
|
||||
@@ -848,6 +848,16 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
}
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
let mut sources = Vec::new();
|
||||
let frame_is_orthonormal = plane.origin.is_finite()
|
||||
&& plane.x.is_finite()
|
||||
&& plane.y.is_finite()
|
||||
&& d.is_finite()
|
||||
&& (plane.x.len2() - 1.).abs() <= 64. * f64::EPSILON
|
||||
&& (plane.y.len2() - 1.).abs() <= 64. * f64::EPSILON
|
||||
&& (d.len2() - 1.).abs() <= 64. * f64::EPSILON
|
||||
&& plane.x.dot(plane.y).abs() <= 64. * f64::EPSILON
|
||||
&& plane.x.dot(d).abs() <= 64. * f64::EPSILON
|
||||
&& plane.y.dot(d).abs() <= 64. * f64::EPSILON;
|
||||
for (first, end, sg) in runs {
|
||||
let (z0, z1) = (levels[first], levels[end]);
|
||||
let (a, b) = (sg.start(), sg.end());
|
||||
@@ -868,7 +878,28 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
|
||||
// Axis-aligned generated lines have a common oriented support frame
|
||||
// independent of axial stations or along-line endpoint interpolation.
|
||||
let generated_plane = if coalesce
|
||||
&& frame_is_orthonormal
|
||||
&& let Seg2::Line { a, b } = sg
|
||||
{
|
||||
let (origin, x) = if a.y == b.y && a.x != b.x {
|
||||
(at(Vec2::new(0., a.y), 0.), plane.x * (b.x - a.x).signum())
|
||||
} else if a.x == b.x && a.y != b.y {
|
||||
(at(Vec2::new(a.x, 0.), 0.), plane.y * (b.y - a.y).signum())
|
||||
} else {
|
||||
(Vec3::ZERO, Vec3::ZERO)
|
||||
};
|
||||
(origin.is_finite() && x.len2() > 0.).then_some(Plane { origin, x, y: d })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
sources.push((generated_plane.is_some() || end > first + 1).then_some(crate::FaceSource::ExtrudeSide {
|
||||
segment: sg,
|
||||
plane: generated_plane,
|
||||
coalesced: end > first + 1,
|
||||
}));
|
||||
}
|
||||
for (z, up, rs) in &caps {
|
||||
for r in rs {
|
||||
@@ -885,7 +916,11 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
sources.push(None);
|
||||
}
|
||||
}
|
||||
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
|
||||
Some(crate::sew::sew_touching_with_sources(&faces, &sources).and_then(
|
||||
|body| {
|
||||
if coalesce { body.regularize_generated_planar_faces() } else { Ok(body) }
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
@@ -1109,6 +1144,8 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
|
||||
&slabs,
|
||||
tol,
|
||||
op == BoolOp::Cut
|
||||
&& !a.regularization_protected
|
||||
&& !b.regularization_protected
|
||||
&& a.split_keep().is_empty()
|
||||
&& b.split_keep().is_empty()
|
||||
&& a.paint().is_none_or(|p| p.faces.is_empty())
|
||||
@@ -1216,6 +1253,62 @@ mod planar_tests {
|
||||
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
|
||||
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
|
||||
}
|
||||
fn stepped_tab_stock() -> Body {
|
||||
let stock = crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap();
|
||||
let tab = crate::box_solid(Vec3::new(-12., -25., 8.), Vec3::new(-4., -15., 16.)).unwrap();
|
||||
crate::boolean(&stock, &tab, BoolOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
fn coplanar_joins(body: &Body) -> usize {
|
||||
let faces: Vec<_> = body.solid.face_iter().collect();
|
||||
let mut adjacency = std::collections::HashMap::<_, Vec<usize>>::new();
|
||||
for (i, face) in faces.iter().enumerate() {
|
||||
for edge in face.edge_iter() {
|
||||
adjacency.entry(edge.id()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
adjacency
|
||||
.values()
|
||||
.filter(|faces| {
|
||||
let [a, b] = faces.as_slice() else { return false };
|
||||
let (Some(pa), Some(pb)) = (body.planar_face(*a), body.planar_face(*b)) else { return false };
|
||||
let (na, nb) = (pa.normal(), pb.normal());
|
||||
na.dot(nb) > 1. - 1e-12 && (pb.origin - pa.origin).dot(na).abs() < 1e-10
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_of_joined_stepped_tabs_has_no_artificial_planar_stations() {
|
||||
let stock = stepped_tab_stock();
|
||||
let snapshot = crate::step_export(&[&stock], "unchanged stock").unwrap();
|
||||
assert_eq!(coplanar_joins(&stock), 0);
|
||||
let shell = crate::shell(&stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap();
|
||||
assert_eq!(coplanar_joins(&shell), 0, "Shell must not add internal edges across continuous planar walls");
|
||||
assert!(shell.validity().is_empty());
|
||||
let mesh = shell.tessellate(1e-5).unwrap();
|
||||
let expected = 40. * 30. * 24. + 8. * 10. * 8. - (38. * 28. * 23. + 6. * 10. * 6.);
|
||||
assert!((mesh.measure().volume - expected).abs() < 1e-6);
|
||||
for (point, material) in [
|
||||
(Vec3::new(0., 0., 0.5), true),
|
||||
(Vec3::new(0., 0., 10.), false),
|
||||
(Vec3::new(0., -14.5, 10.), true),
|
||||
(Vec3::new(19.5, 0., 10.), true),
|
||||
(Vec3::new(-8., -24.5, 12.), true),
|
||||
(Vec3::new(-8., -20., 12.), false),
|
||||
(Vec3::new(0., -18., 9.), false),
|
||||
] {
|
||||
assert_eq!(mesh.contains(point), material, "wrong material at {point:?}");
|
||||
}
|
||||
let step = crate::step_export(&[&shell], "stepped tab shell").unwrap();
|
||||
let reopened = crate::step_import(&step).unwrap();
|
||||
let body = &reopened.bodies[0].body;
|
||||
assert_eq!(body.face_count(), shell.face_count());
|
||||
assert_eq!(coplanar_joins(body), 0);
|
||||
assert!((body.tessellate(1e-5).unwrap().measure().volume - expected).abs() < 1e-6);
|
||||
assert_eq!(crate::step_export(&[&stock], "unchanged stock").unwrap(), snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
|
||||
for curved in [false, true] {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
fn stamped_stock() -> Body {
|
||||
crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap().with_box_certificate(7).unwrap()
|
||||
}
|
||||
|
||||
fn cup(stock: &Body) -> Body {
|
||||
crate::shell(stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap().with_shell_certificate(9).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
|
||||
let stock = stamped_stock();
|
||||
let ordinary = cup(&stock);
|
||||
assert!(!ordinary.regularization_protected);
|
||||
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let side = stock
|
||||
.solid
|
||||
.face_iter()
|
||||
.enumerate()
|
||||
.find(|(i, _)| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.))
|
||||
.unwrap()
|
||||
.0;
|
||||
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
|
||||
assert!(split.split_lines().is_some());
|
||||
for protected in [painted, split] {
|
||||
let result = cup(&protected);
|
||||
assert!(result.regularization_protected);
|
||||
assert!(result.face_count() > ordinary.face_count(), "authored boundaries must survive both Shell regularizers");
|
||||
assert!(result.validity().is_empty());
|
||||
let expected = 40. * 30. * 24. - 38. * 28. * 23.;
|
||||
let mesh = result.tessellate(1e-5).unwrap();
|
||||
assert!((mesh.measure().volume - expected).abs() < 1e-6);
|
||||
assert!(mesh.contains(Vec3::new(19.5, 0., 12.)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 12.)));
|
||||
let count = result.face_count();
|
||||
assert_eq!(result.clone().regularize_certified_planar_faces().unwrap().face_count(), count);
|
||||
assert_eq!(result.clone().regularize_generated_planar_faces().unwrap().face_count(), count);
|
||||
let placed = crate::transform(&result, Vec3::new(30., -40., 7.), Vec3::ZERO, Vec3::Z, 0.3).unwrap();
|
||||
assert!(placed.regularization_protected);
|
||||
for lump in placed.lumps().unwrap() {
|
||||
assert!(lump.regularization_protected);
|
||||
}
|
||||
let cleared = result.clone().with_paint(None).with_split_lines(None);
|
||||
assert!(cleared.regularization_protected, "cleared appearance must not erase inherited boundary protection");
|
||||
assert_eq!(cleared.regularize_certified_planar_faces().unwrap().face_count(), count);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
|
||||
let a = stamped_stock();
|
||||
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
|
||||
let cleared = protected.with_paint(None);
|
||||
assert!(!a.same(&cleared));
|
||||
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
|
||||
let cut = crate::boolean(&a, &tool, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.regularization_protected);
|
||||
let remote = crate::box_solid(Vec3::new(100., 0., 0.), Vec3::new(110., 10., 10.)).unwrap();
|
||||
let joined = crate::ops::join_pieces(vec![cleared, remote]).unwrap().unwrap();
|
||||
assert!(joined.regularization_protected);
|
||||
assert_eq!(joined.lumps().unwrap().len(), 2);
|
||||
assert!(joined.lumps().unwrap().iter().all(|body| body.regularization_protected));
|
||||
}
|
||||
@@ -886,6 +886,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
paint: None,
|
||||
splits: body.splits.clone(),
|
||||
face_sources: None,
|
||||
regularization_protected: true,
|
||||
},
|
||||
imp,
|
||||
))
|
||||
|
||||
@@ -30,6 +30,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user