1 parent
180e62c981
commit
487aa64e10
21 files changed
+1624
-78
No files matched your search
+19
@@ -274,6 +274,25 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
|
||||
+115
-19
@@ -1581,6 +1581,29 @@ fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>,
|
||||
apply_traced(st, f, tools, op, &rest)
|
||||
}
|
||||
|
||||
/// Only one target meets every separated tool. A tool with no contact must retain
|
||||
/// Join's first-target fallback; tools touching different targets must retain absorption order.
|
||||
fn common_join_target(state: &ModelState, indices: &[usize], tools: &[Traced]) -> Result<Option<usize>> {
|
||||
if indices.len() > 64 {
|
||||
return Ok(None);
|
||||
}
|
||||
let bodies: Vec<_> = tools.iter().map(|tool| tool.body.clone()).collect();
|
||||
let mut contacts = vec![None; tools.len()];
|
||||
for &index in indices {
|
||||
let body = state.bodies.get(index).ok_or_else(|| DocError::Invalid("join target disappeared".into()))?;
|
||||
for (contact, meets) in contacts.iter_mut().zip(body.body.meets_each(&bodies)?) {
|
||||
if meets {
|
||||
if contact.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
*contact = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let common = contacts.first().copied().flatten();
|
||||
Ok(common.filter(|index| contacts.iter().all(|contact| *contact == Some(*index))))
|
||||
}
|
||||
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
|
||||
}
|
||||
@@ -1636,6 +1659,41 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
}
|
||||
}
|
||||
}
|
||||
// Batching solid targets must preserve Join's absorption order,
|
||||
// sheet material tracking and the separate bodies of a New Body extrusion.
|
||||
if tools.len() > 1
|
||||
&& matches!(f.kind, FeatureKind::Extrude { .. })
|
||||
&& matches!(op, Operation::Join | Operation::Cut)
|
||||
&& !target_idx.is_empty()
|
||||
&& target_idx.iter().all(|i| state.bodies.get(*i).is_some_and(|b| !b.body.is_mesh() && !state.sheets.iter().any(|s| s.body == b.name)))
|
||||
&& let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&tools.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
|
||||
{
|
||||
let target = if target_idx.len() == 1 {
|
||||
target_idx.first().copied()
|
||||
} else if op == Operation::Join {
|
||||
common_join_target(state, &target_idx, &tools).ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The optimization must not reject geometry the original sequential path can
|
||||
// handle. A failed batch leaves the model untouched and retries the original tools.
|
||||
let batch_targets = if op == Operation::Cut {
|
||||
// Subtracting a union equals subtracting its separated tools in order.
|
||||
// Keep ALL original participants; no sampled contact test excludes a body.
|
||||
Some(target_idx.iter().filter_map(|index| state.bodies.get(*index).map(|body| body.name.clone())).collect::<Vec<_>>())
|
||||
} else {
|
||||
target.and_then(|index| state.bodies.get(index)).map(|body| vec![body.name.clone()])
|
||||
};
|
||||
if let Some(names) = batch_targets {
|
||||
let mut batched = state.clone();
|
||||
// Preserve the original participants. For Join, the compound's larger
|
||||
// size must not expand the contact tolerance and absorb a nearby body.
|
||||
if apply_traced(&mut batched, f, vec![Traced::step(compound, &tools.iter().collect::<Vec<_>>())], op, &names).is_ok() {
|
||||
*state = batched;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
match op {
|
||||
Operation::Join => {
|
||||
// Each tool joins every target it meets into one body, kept under the first one's
|
||||
@@ -3219,25 +3277,63 @@ fn feature_tools_with_constructor(
|
||||
// Each tool's extent along the normal (they differ where one is run on into a face).
|
||||
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
|
||||
let plain_targets: Vec<String> = crate::plain(targets);
|
||||
let surroundings = Surroundings::of(st, *operation, &plain_targets);
|
||||
// Trimming may discard a tool's material, so only bodies this feature
|
||||
// actually joins can establish that the discarded end is already filled.
|
||||
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
|
||||
let trim_targets: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
if participants.is_empty() {
|
||||
b.component == f.component || b.feature == f.id
|
||||
} else {
|
||||
participants.iter().any(|t| t == "*" || t == &b.name)
|
||||
}
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
let join_surroundings =
|
||||
if *operation == Operation::Join && !trim_targets.is_empty() { Surroundings::of(st, *operation, &trim_targets) } else { None };
|
||||
for r in ®ions {
|
||||
let exact_planar_cut = *operation == Operation::Cut
|
||||
&& ss.sketch.links.len() > 1
|
||||
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
|
||||
&& st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|body| plain_targets.is_empty() || plain_targets.iter().any(|target| target == "*" || target == &body.name))
|
||||
.all(|body| kernel::is_planar(&body.body));
|
||||
let mut touching_batch = false;
|
||||
// Generated face profiles have full source seams. Check all of them in
|
||||
// one guarded construction; arbitrary sketch profiles retain their existing path.
|
||||
if *operation == Operation::Join
|
||||
&& regions.len() > 1
|
||||
&& ss.sketch.links.len() > 1
|
||||
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
|
||||
{
|
||||
let exact = regions
|
||||
.iter()
|
||||
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&exact.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
|
||||
&& st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|body| plain_targets.is_empty() || plain_targets.contains(&body.name))
|
||||
.any(|body| kernel::can_join_all_touching_faces(&body.body, &compound))
|
||||
{
|
||||
tools = exact;
|
||||
touching_batch = true;
|
||||
}
|
||||
}
|
||||
let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) };
|
||||
let join_surroundings = if *operation == Operation::Join && !touching_batch {
|
||||
// Trimming may discard a tool's material, so only bodies this feature
|
||||
// actually joins can establish that the discarded end is already filled.
|
||||
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
|
||||
let trim_targets: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
if participants.is_empty() {
|
||||
b.component == f.component || b.feature == f.id
|
||||
} else {
|
||||
participants.iter().any(|t| t == "*" || t == &b.name)
|
||||
}
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
|
||||
if trim_targets.is_empty() { None } else { Surroundings::of(st, *operation, &trim_targets) }
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for r in regions.iter().filter(|_| !touching_batch) {
|
||||
if *operation == Operation::Join {
|
||||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
|
||||
if exact.iter().all(|tool| {
|
||||
|
||||
@@ -584,3 +584,227 @@ fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
|
||||
assert_eq!(mesh.contains(point), material, "material at {point:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_extrusion_profiles_preserve_material_and_body_identity() {
|
||||
for operation in ["join", "cut", "new"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Stock"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
|
||||
for i in 0..6 {
|
||||
for j in 0..4 {
|
||||
let (x, y) = (2 + i * 6, 2 + j * 7);
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, y], "p1": [x + 4, y + 4]})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let distance = if operation == "cut" { -5 } else { 5 };
|
||||
let before = s.revision;
|
||||
s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap();
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), if operation == "new" { 25 } else { 1 });
|
||||
let expected = 24_000.0
|
||||
+ match operation {
|
||||
"join" => 1920.0,
|
||||
"cut" => -1920.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
|
||||
let stock = state.body("Stock").unwrap();
|
||||
for (point, present) in [([3.0, 3.0, 22.0], operation == "join"), ([3.0, 3.0, 18.0], operation != "cut"), ([7.0, 3.0, 18.0], true)] {
|
||||
assert_eq!(stock.mesh().contains(solidcraft_geom::Vec3::from(point)), present, "{operation}: {point:?}");
|
||||
}
|
||||
assert!(stock.body.validity().is_empty());
|
||||
assert!(s.revision > before);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Stock") - 24_000.0).abs() < 1e-6);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_circular_extrusion_profiles_cut_a_curved_target() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.cylinder", &json!({"radius": 20, "height": 20, "body_name": "Stock"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, -1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
|
||||
for x in [-9, -3, 3, 9] {
|
||||
for y in [-9, -3, 3, 9] {
|
||||
s.execute("sketch.circle.center", &json!({"center": [x, y], "radius": 1})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 22, "operation": "cut", "targets": ["Stock"]})).unwrap();
|
||||
// The rebuilt caps mesh more finely than the original cylinder. Compare with the
|
||||
// analytic material volume, rather than subtracting two differently faceted meshes.
|
||||
let expected = (400.0 - 16.0) * std::f64::consts::PI * 20.0;
|
||||
let actual = volume(&s, "Stock");
|
||||
assert!((actual - expected).abs() < expected * 1e-5, "{actual} vs {expected}");
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
let stock = state.body("Stock").unwrap();
|
||||
assert!(stock.body.validity().is_empty());
|
||||
let mesh = stock.mesh();
|
||||
assert!(mesh.contains(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)));
|
||||
for x in [-9.0, -3.0, 3.0, 9.0] {
|
||||
for y in [-9.0, -3.0, 3.0, 9.0] {
|
||||
assert!(!mesh.contains(solidcraft_geom::Vec3::new(x, y, 10.0)), "hole at {x}, {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coplanar_body_faces_extrude_together_and_replay_their_links() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":64,"width":64,"height":20,"body_name":"Plate"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
let mut faces = Vec::new();
|
||||
for i in 0..8 {
|
||||
for j in 0..8 {
|
||||
let (x, y) = (2 + i * 7, 2 + j * 7);
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,y],"p1":[x+4,y+4]})).unwrap();
|
||||
faces.push(json!([x + 2, y + 2, 26]));
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance":6,"operation":"join"})).unwrap();
|
||||
let (features, undo) = (s.doc.features.len(), s.undo.len());
|
||||
s.execute("solid.extrude", &json!({"faces":faces,"distance":-7,"operation":"cut"})).unwrap();
|
||||
assert_eq!(s.doc.features.len(), features + 2, "one linked sketch and one extrusion");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let actual = volume(&s, "Plate");
|
||||
let state = s.model.state();
|
||||
let sketch = state.sketch(4).unwrap();
|
||||
assert!((actual - 80_896.0).abs() < 1e-5, "volume {actual}; plane {:?}; profiles {}", sketch.plane, sketch.profiles.len());
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let document = serde_json::from_value(serde_json::to_value(s.doc.as_ref()).unwrap()).unwrap();
|
||||
let mut reopened = Session { doc: std::sync::Arc::new(document), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((volume(&reopened, "Plate") - 80_896.0).abs() < 1e-5);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Plate") - 80_896.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_face_groups_are_atomic() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let before = serde_json::to_value(s.doc.as_ref()).unwrap();
|
||||
let (revision, undo) = (s.revision, s.undo.len());
|
||||
for faces in [
|
||||
json!([]),
|
||||
json!([[5, 5, 10], [5, 5, 0]]),
|
||||
json!([[5, 5, 10], [5, 0, 5]]),
|
||||
json!([[5, 5, 10], [1000, 1000, 1000]]),
|
||||
json!(vec![[5, 5, 10]; 1025]),
|
||||
] {
|
||||
assert!(s.execute("solid.extrude", &json!({"faces":faces,"distance":5,"operation":"join"})).is_err());
|
||||
assert_eq!(serde_json::to_value(s.doc.as_ref()).unwrap(), before);
|
||||
assert_eq!((s.revision, s.undo.len()), (revision, undo));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let mut source = Session::default();
|
||||
source.execute("solid.box", &json!({"corner":[-16,-16,-3],"length":32,"width":32,"height":1,"body_name":"Other"})).unwrap();
|
||||
source.execute("solid.cylinder", &json!({"radius":14,"height":8,"body_name":"Stock"})).unwrap();
|
||||
let mut outlines = Vec::new();
|
||||
let mut centers = Vec::new();
|
||||
for i in 0..12 {
|
||||
let angle = i as f64 * std::f64::consts::TAU / 12.;
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
let outline: Vec<_> =
|
||||
[(-2., -2.), (2., -2.), (2., 2.), (-2., 2.)].into_iter().map(|(x, y)| [10.7 * c + x * c - y * s, 10.7 * s + x * s + y * c]).collect();
|
||||
outlines.push(outline);
|
||||
centers.push([10.7 * c, 10.7 * s, 12.]);
|
||||
}
|
||||
let draw = |session: &mut Session, height: f64| {
|
||||
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,height],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for outline in &outlines {
|
||||
session.execute("sketch.line", &json!({"points":outline,"closed":true,"infer":false})).unwrap();
|
||||
}
|
||||
session.execute("sketch.finish", &json!({})).unwrap();
|
||||
};
|
||||
draw(&mut source, 8.);
|
||||
source.execute("solid.extrude", &json!({"distance":4,"operation":"join"})).unwrap();
|
||||
// An original public-safe STEP example, with rotated full-face seams and a
|
||||
// curved target. No vendor/user design is part of this regression.
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-face-extrusion-{}.step", std::process::id()));
|
||||
source.execute("file.export", &json!({"path":path.to_string_lossy()})).unwrap();
|
||||
let mut imported = Session::default();
|
||||
imported.execute("doc.open", &json!({"path":path.to_string_lossy()})).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let original = imported.model.state();
|
||||
let other = original.body("Other").unwrap().body.clone();
|
||||
for (operation, distance) in [("join", 2.), ("cut", -1.), ("new", 2.), ("intersect", -1.)] {
|
||||
let mut grouped = imported.scratch();
|
||||
grouped.execute("solid.extrude", &json!({"faces":centers,"distance":distance,"operation":operation})).unwrap();
|
||||
let mut reference = imported.scratch();
|
||||
draw(&mut reference, 12.);
|
||||
if matches!(operation, "join" | "cut") {
|
||||
// Individual tools retain the original Boolean and participant path.
|
||||
for index in 0..12 {
|
||||
reference
|
||||
.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":distance,"operation":operation}))
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
reference.execute("solid.extrude", &json!({"distance":distance,"operation":operation})).unwrap();
|
||||
}
|
||||
let actual = grouped.model.state();
|
||||
let expected = reference.model.state();
|
||||
assert_eq!(actual.bodies.len(), expected.bodies.len(), "{operation}");
|
||||
for body in &actual.bodies {
|
||||
let reference = expected.body(&body.name).unwrap();
|
||||
// Fixed fine chord tolerance: cap subdivision can change the default
|
||||
// adaptive mesh's cylinder faceting without changing its analytic surface.
|
||||
let (a, b) = (body.body.tessellate(1e-5).unwrap().measure().volume, reference.body.tessellate(1e-5).unwrap().measure().volume);
|
||||
assert!((a - b).abs() < b.max(1.) * 1e-6, "{operation}: {} {a} vs {b}", body.name);
|
||||
assert!(body.body.validity().is_empty(), "{operation}: {}", body.name);
|
||||
let (a, b) = (body.mesh(), reference.mesh());
|
||||
for center in ¢ers {
|
||||
for z in [7., 11.5, 13.] {
|
||||
let point = Vec3::new(center[0], center[1], z);
|
||||
assert_eq!(a.contains(point), b.contains(point), "{operation}: {} {point:?}", body.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if operation != "intersect" {
|
||||
let unchanged = &actual.body("Other").unwrap().body;
|
||||
assert_eq!(unchanged.face_count(), other.face_count());
|
||||
assert_eq!(unchanged.color(), other.color());
|
||||
assert_eq!(unchanged.tessellate(0.01).unwrap().positions, other.tessellate(0.01).unwrap().positions, "unaffected imported body");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
|
||||
for (second_x, expected_count) in [(10.0015, 2), (9., 1)] {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
|
||||
session.execute("solid.box", &json!({"corner":[second_x,8,10],"length":1,"width":1,"height":2,"body_name":"Other"})).unwrap();
|
||||
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for origin in [[0., 0.], [8., 8.]] {
|
||||
session.execute("sketch.rectangle.two_point", &json!({"p0":origin,"p1":[origin[0]+2.,origin[1]+2.]})).unwrap();
|
||||
}
|
||||
session.execute("sketch.finish", &json!({})).unwrap();
|
||||
let mut reference = session.scratch();
|
||||
for index in 0..2 {
|
||||
reference.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":2,"operation":"join"})).unwrap();
|
||||
}
|
||||
session.execute("solid.extrude", &json!({"distance":2,"operation":"join"})).unwrap();
|
||||
assert_eq!(session.model.state().bodies.len(), expected_count);
|
||||
assert_eq!(
|
||||
session.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>(),
|
||||
reference.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
for body in &session.model.state().bodies {
|
||||
assert!((volume(&session, &body.name) - volume(&reference, &body.name)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,10 @@ type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
|
||||
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
|
||||
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let st = s.model.state();
|
||||
face_at_state(&st, p, only)
|
||||
}
|
||||
|
||||
pub(super) fn face_at_state(st: &solidcraft_doc::ModelState, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let mut best: Option<FacePick> = None;
|
||||
let mut tied: Vec<String> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
|
||||
@@ -127,6 +131,80 @@ fn xy(v: Vec2) -> Value {
|
||||
json!([v.x, v.y])
|
||||
}
|
||||
|
||||
/// Coplanar faces share one linked profile sketch and one Extrude feature. Resolve every
|
||||
/// pick before editing the document, so later picks never search an already modified body.
|
||||
pub(super) fn sketch_of_faces(s: &mut Session, points: &[Vec3], cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec<Vec2>, String)> {
|
||||
let first = points.first().copied().ok_or_else(|| bad(cmd, "faces must not be empty"))?;
|
||||
if points.len() > 1024 {
|
||||
return Err(bad(cmd, "faces exceed the 1024-face limit"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let mut picked = Vec::new();
|
||||
let mut common: Option<(String, solidcraft_geom::Plane)> = None;
|
||||
for &point in points {
|
||||
if !point.is_finite() {
|
||||
return Err(bad(cmd, "faces must be finite points"));
|
||||
}
|
||||
let (mesh, index, distance, name) = face_at_in(s, point, face_body)
|
||||
.map_err(|_| bad(cmd, "ambiguous face; specify face_body"))?
|
||||
.ok_or_else(|| bad(cmd, "no body face at a selected point"))?;
|
||||
let body = state.body(&name).ok_or_else(|| bad(cmd, "selected body disappeared"))?;
|
||||
let plane = body.body.planar_face(index as usize).ok_or_else(|| bad(cmd, "faces must be planar"))?;
|
||||
let tolerance = (body.body.size() * 1e-9).max(1e-8);
|
||||
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
|
||||
return Err(bad(cmd, "no body face at a selected point"));
|
||||
}
|
||||
if let Some((previous, base)) = &common {
|
||||
if *previous != name || base.normal().dot(plane.normal()) < 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
|
||||
return Err(bad(cmd, "faces must be coplanar, equally oriented faces of one body"));
|
||||
}
|
||||
} else {
|
||||
common = Some((name.clone(), plane));
|
||||
}
|
||||
if picked.iter().any(|(face, _)| *face == index as usize) {
|
||||
continue;
|
||||
}
|
||||
let inside = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, face)| **face == index)
|
||||
.filter_map(|(triangle, _)| mesh.tri(triangle))
|
||||
.max_by(|[a, b, c], [x, y, z]| ((*b - *a).cross(*c - *a).len()).total_cmp(&((*y - *x).cross(*z - *x).len())))
|
||||
.map(|[a, b, c]| (a + b + c) / 3.0)
|
||||
.ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
picked.push((index as usize, inside));
|
||||
}
|
||||
let (id, first_inside, _) = sketch_of_face(s, first, cmd, face_body)?;
|
||||
let (body, _) = common.ok_or_else(|| bad(cmd, "no faces selected"))?;
|
||||
let doc = s.placed_document();
|
||||
let source = state.body(&body).ok_or_else(|| bad(cmd, "source body disappeared"))?;
|
||||
let component = doc.model_body_component(source);
|
||||
let path = if component == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
|
||||
} else {
|
||||
let placements = super::component::properties::placements(&doc, cmd)?;
|
||||
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
|
||||
return Err(bad(cmd, "activate the selected occurrence before extruding its faces"));
|
||||
}
|
||||
doc.first_path(component)
|
||||
};
|
||||
let placement = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
let placement = doc.model_body_offset(source).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
|
||||
let into_author = mat_mul(&inverse, &placement);
|
||||
drop(doc);
|
||||
let indices: Vec<_> = picked.iter().skip(1).map(|(index, _)| *index).collect();
|
||||
super::sketch_project::project_internal_faces(s, id, &body, &indices)?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(id).map(|sketch| sketch.plane).ok_or_else(|| bad(cmd, "face profile sketch disappeared"))?;
|
||||
let mut selected = vec![first_inside];
|
||||
selected.extend(picked.iter().skip(1).map(|(_, point)| plane.to_local(apply_point(&into_author, *point))));
|
||||
Ok((id, selected, body))
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
|
||||
/// The sketch is left active.
|
||||
|
||||
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -118,7 +118,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
|
||||
/// face, and the unit normal.
|
||||
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
if let Some(fp) = p.get("face").and_then(vec3) {
|
||||
if let Some(fp) = p.get("face").or_else(|| p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first())).and_then(vec3) {
|
||||
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
|
||||
}
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
@@ -167,19 +167,38 @@ const START_PROBE_MM: f64 = 0.1;
|
||||
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
|
||||
/// past a body's far side read as a Join (#1257).
|
||||
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let d = extrude_reach(s, p)?;
|
||||
auto_operation_in(s, p, true)
|
||||
}
|
||||
|
||||
fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'static str> {
|
||||
let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric"));
|
||||
let (base, n) = extrude_base(s, p)?;
|
||||
let st = s.model.state();
|
||||
// Imported face groups can belong to a child of an empty active component.
|
||||
// Probe in the same coordinate frame as the face picks.
|
||||
let grouped = p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first()).and_then(vec3);
|
||||
let st = if grouped.is_some() && world_faces { s.world_state() } else { s.model.state() };
|
||||
let (base, n) = if let Some(point) = grouped {
|
||||
let (mesh, index, distance, name) = super::face::face_at_state(&st, point, str_(p, "face_body")).ok().flatten()?;
|
||||
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
|
||||
return None;
|
||||
}
|
||||
let normal = st.body(&name)?.body.planar_face(index as usize)?.normal();
|
||||
(point, normal)
|
||||
} else {
|
||||
extrude_base(s, p)?
|
||||
};
|
||||
let d = if grouped.is_some() && p.get("to").is_some() { (p.get("to").and_then(vec3)? - base).dot(n) } else { extrude_reach(s, p)? };
|
||||
if st.bodies.is_empty() || !d.is_finite() {
|
||||
return Some("new");
|
||||
}
|
||||
// Only the active component's bodies take part (or the ones named as participants).
|
||||
// Match the evaluator's implicit participants: a grouped cut from an empty
|
||||
// assembly component considers every body; otherwise keep the active scope.
|
||||
// Explicit participants override either default.
|
||||
let parts = string_list(p, "participants");
|
||||
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
@@ -470,7 +489,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
// takes part in every body instead, which it changes only where it meets them.
|
||||
let participants = if participants.is_empty()
|
||||
&& !primitive
|
||||
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|
||||
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|
||||
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|
||||
&& {
|
||||
let st = s.model.state();
|
||||
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
|
||||
@@ -545,13 +565,31 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.extrude";
|
||||
let grouped = if let Some(value) = p.get("faces") {
|
||||
if p.get("face").is_some() || p.get("sketch").is_some() || p.get("profiles").is_some() {
|
||||
return Err(bad(cmd, "faces cannot be combined with face, sketch or profiles"));
|
||||
}
|
||||
let faces = value
|
||||
.as_array()
|
||||
.filter(|faces| !faces.is_empty() && faces.len() <= 1024)
|
||||
.ok_or_else(|| bad(cmd, "faces must contain between 1 and 1024 points"))?;
|
||||
let points = faces.iter().map(|point| vec3(point).ok_or_else(|| bad(cmd, "faces must be points [x,y,z]"))).collect::<Result<Vec<_>>>()?;
|
||||
let (id, points, body) = super::face::sketch_of_faces(s, &points, cmd, str_(p, "face_body"))?;
|
||||
Some((id, Some(ProfileSel::Points { points }), Some(body)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
|
||||
let (sketch, face_profile, face_body) = if let Some(grouped) = grouped {
|
||||
grouped
|
||||
} else {
|
||||
match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None, None),
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let thin = match bool_(p, "thin") {
|
||||
@@ -565,7 +603,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
let operation = match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation_in(s, p, false).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
};
|
||||
let mut targets = string_list(p, "targets");
|
||||
|
||||
@@ -2,6 +2,63 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
for offset in [0., 30.] {
|
||||
if offset != 0. {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":part["occurrence"],"translate":[offset,0,0]}));
|
||||
}
|
||||
// To Object follows the target's side, even when the distance and
|
||||
// direction fields still describe the opposite extrusion.
|
||||
for (height, expected) in [(8., "cut"), (12., "join")] {
|
||||
let params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"face_body":"Stock",
|
||||
"to":[offset+5.,5,height],"direction":"negative"});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
}
|
||||
for distance in [-2., 2.] {
|
||||
let mut params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"distance":distance});
|
||||
let expected = if distance < 0. { "cut" } else { "join" };
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
params["face_body"] = json!("Stock");
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
let mut actual = s.scratch();
|
||||
if offset != 0. {
|
||||
run(&mut actual, "component.activate", json!({"component":part["component"]}));
|
||||
}
|
||||
params["operation"] = json!("auto");
|
||||
let made = run(&mut actual, "solid.extrude", params);
|
||||
let body = actual.model.state().body("Stock").unwrap().body.clone();
|
||||
let volume = solidcraft_kernel::measure(&body).unwrap().volume;
|
||||
assert!(
|
||||
(volume - (1000. + 100. * distance)).abs() < 1e-6,
|
||||
"offset={offset}, distance={distance}, volume={volume}, feature={:?}",
|
||||
actual.doc.feature(made["feature"].as_u64().unwrap())
|
||||
);
|
||||
}
|
||||
if offset != 0. {
|
||||
// Outside the edited component, grouping keeps the original explicit
|
||||
// face command's coordinate and participant behavior.
|
||||
let mut grouped = s.scratch();
|
||||
let mut single = s.scratch();
|
||||
run(&mut grouped, "solid.extrude", json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2,"operation":"cut"}));
|
||||
run(&mut single, "solid.extrude", json!({"face":[32,2,10],"face_body":"Stock","distance":-2,"operation":"cut"}));
|
||||
let volume = |session: &Session| solidcraft_kernel::measure(&session.model.state().body("Stock").unwrap().body).unwrap().volume;
|
||||
assert!((volume(&grouped) - volume(&single)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
|
||||
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"), "an existing active-component body keeps its original participant scope");
|
||||
params["participants"] = json!(["Stock"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("cut"));
|
||||
params["participants"] = json!(["Other"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"));
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
@@ -28,6 +85,43 @@ fn volume(s: &mut Session) -> f64 {
|
||||
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_profile_cut_preserves_participants_and_removes_only_consumed_bodies() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 10), ("C", 30)] {
|
||||
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":10,"corner":[x,0,0],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Cutters"}));
|
||||
for (a, b) in [([-1, -1], [6, 6]), ([12, 2], [14, 4]), ([32, 2], [34, 4])] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":a,"p1":b}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let mut params = json!({"sketch":"Cutters","profiles":"all","distance":10,"operation":"cut"});
|
||||
if explicit {
|
||||
params["participants"] = json!(["A", "B"]);
|
||||
}
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
assert!(state.body("A").is_none());
|
||||
for (name, expected) in [("B", 210.), ("C", if explicit { 250. } else { 210. })] {
|
||||
let body = &state.body(name).unwrap().body;
|
||||
assert!((solidcraft_kernel::measure(body).unwrap().volume - expected).abs() < 1e-6);
|
||||
assert!(body.validity().is_empty());
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
@@ -520,21 +520,34 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
/// Private compatibility projection for generated face profiles. Named legacy links follow
|
||||
/// edits, but cannot provide the construction certificate required by public attachments.
|
||||
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
|
||||
project_internal_faces(s, id, body, &[index])?.pop().ok_or_else(|| bad("face profile", "empty face projection"))
|
||||
}
|
||||
|
||||
/// Capture individual replay names together, against the same unchanged source body.
|
||||
pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indices: &[usize]) -> Result<Vec<String>> {
|
||||
if indices.len() > 1024 {
|
||||
return Err(bad("face profile", "too many faces"));
|
||||
}
|
||||
let mut doc = (*s.doc).clone();
|
||||
let raw = s.model.state_before(id);
|
||||
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
|
||||
let plane = sketch_plane(&doc, id, &state)?;
|
||||
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
|
||||
let names = state.body(body).map(solidcraft_doc::naming::face_names).unwrap_or_default();
|
||||
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
entry.face_name = face_name;
|
||||
let mut links = Vec::with_capacity(indices.len());
|
||||
for &index in indices {
|
||||
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
entry.face_name = names.get(index).cloned();
|
||||
}
|
||||
links.push(link);
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(link)
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
|
||||
@@ -46,7 +46,9 @@ use K::*;
|
||||
/// The parameters each command takes in world coordinates.
|
||||
fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
match id {
|
||||
"solid.extrude" => &[("face", Pick), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.extrude" => {
|
||||
&[("face", Pick), ("faces", Picks), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)]
|
||||
}
|
||||
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
|
||||
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
|
||||
|
||||
@@ -54,6 +54,24 @@ fn differs(want: &Value, got: &Value) -> Option<String> {
|
||||
(!out.is_empty()).then(|| out.join("; "))
|
||||
}
|
||||
|
||||
/// Platform records only override the seeds explicitly verified on that platform.
|
||||
fn expected<'a>(file: &'a Value, seed: u64, platform: &str) -> &'a Value {
|
||||
let key = seed.to_string();
|
||||
file.get("platforms")
|
||||
.and_then(|platforms| platforms.get(platform))
|
||||
.and_then(|record| record.get("seeds"))
|
||||
.and_then(|seeds| seeds.get(&key))
|
||||
.unwrap_or(&file["seeds"][key])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_records_keep_shared_references_for_every_other_seed_and_platform() {
|
||||
let file = json!({"seeds":{"1":{"volume":10},"2":{"volume":20}},"platforms":{"windows":{"seeds":{"1":{"volume":11}}}}});
|
||||
assert_eq!(expected(&file, 1, "windows")["volume"], 11);
|
||||
assert_eq!(expected(&file, 1, "linux")["volume"], 10);
|
||||
assert_eq!(expected(&file, 2, "windows")["volume"], 20);
|
||||
}
|
||||
|
||||
fn check(seed: u64) {
|
||||
let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]);
|
||||
let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -61,7 +79,7 @@ fn check(seed: u64) {
|
||||
let changed: Vec<(u64, String)> = got
|
||||
.iter()
|
||||
.filter_map(|(seed, g)| {
|
||||
let w = &file["seeds"][seed.to_string()];
|
||||
let w = expected(&file, *seed, std::env::consts::OS);
|
||||
if w.is_null() { Some((*seed, "new".to_string())) } else { differs(w, g).map(|d| (*seed, d)) }
|
||||
})
|
||||
.collect();
|
||||
@@ -75,20 +93,34 @@ fn check(seed: u64) {
|
||||
"What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\"."
|
||||
);
|
||||
}
|
||||
let platform = std::env::consts::OS;
|
||||
let platform_record = file["platforms"][platform]["seeds"].is_object();
|
||||
for (seed, g) in &got {
|
||||
file["seeds"][seed.to_string()] = g.clone();
|
||||
if platform_record {
|
||||
if changed.iter().any(|(changed, _)| changed == seed) {
|
||||
file["platforms"][platform]["seeds"][seed.to_string()] = g.clone();
|
||||
}
|
||||
} else {
|
||||
file["seeds"][seed.to_string()] = g.clone();
|
||||
}
|
||||
}
|
||||
let mut notes = file["notes"].as_array().cloned().unwrap_or_default();
|
||||
let seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect();
|
||||
// The parts of one record (the tests run side by side) share one note.
|
||||
match notes.last_mut() {
|
||||
Some(last) if last["reason"] == json!(reason) => {
|
||||
Some(last) if last["reason"] == json!(reason) && (!platform_record || last["platform"] == json!(platform)) => {
|
||||
let mut all: Vec<u64> = last["seeds"].as_array().into_iter().flatten().filter_map(Value::as_u64).chain(seeds).collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
last["seeds"] = json!(all);
|
||||
}
|
||||
_ => notes.push(json!({"reason": reason, "seeds": seeds})),
|
||||
_ => {
|
||||
let mut note = json!({"reason": reason, "seeds": seeds});
|
||||
if platform_record {
|
||||
note["platform"] = json!(platform);
|
||||
}
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
file["notes"] = Value::Array(notes);
|
||||
// Seeds in order, one per line, so a change is a small diff.
|
||||
@@ -100,7 +132,11 @@ fn check(seed: u64) {
|
||||
s += &format!(" \"{k}\": {}{}\n", v, if i + 1 < n { "," } else { "" });
|
||||
}
|
||||
let notes: Vec<String> = file["notes"].as_array().into_iter().flatten().map(|n| format!(" {n}")).collect();
|
||||
s += &format!(" }},\n \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
|
||||
s += " },\n";
|
||||
if let Some(platforms) = file.get("platforms") {
|
||||
s += &format!(" \"platforms\": {platforms},\n");
|
||||
}
|
||||
s += &format!(" \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
|
||||
std::fs::write(path(), s).unwrap_or_else(|e| panic!("{}: {e}", path().display()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1494,6 +1494,33 @@ impl Body {
|
||||
shell_groups::meshes_meet(&a, &b, 2.0 * tol)
|
||||
}
|
||||
|
||||
/// The same contact test for several tools, reusing this unchanged body's
|
||||
/// fine mesh at each required tolerance. No contact threshold is relaxed.
|
||||
pub fn meets_each(&self, others: &[Body]) -> Result<Vec<bool>> {
|
||||
if others.len() > 1024 {
|
||||
return Err(KernelError::Invalid("too many contact tools".into()));
|
||||
}
|
||||
self.require_brep("contact test")?;
|
||||
let size = self.size();
|
||||
let mut meshes = std::collections::HashMap::new();
|
||||
let mut contacts = Vec::with_capacity(others.len());
|
||||
for other in others {
|
||||
other.require_brep("contact test")?;
|
||||
let tol = (size.max(other.size()) * 1e-4).max(1e-6);
|
||||
let key = tol.to_bits();
|
||||
if !meshes.contains_key(&key) {
|
||||
// Bound retained meshes even when hostile inputs request many tolerances.
|
||||
if meshes.len() == 16 {
|
||||
meshes.clear();
|
||||
}
|
||||
meshes.insert(key, self.tessellate(tol)?);
|
||||
}
|
||||
let mesh = meshes.get(&key).ok_or_else(|| KernelError::Internal("contact mesh disappeared".into()))?;
|
||||
contacts.push(shell_groups::meshes_meet(mesh, &other.tessellate(tol)?, 2.0 * tol)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
|
||||
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
|
||||
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
|
||||
|
||||
@@ -1193,10 +1193,18 @@ pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool)
|
||||
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
|
||||
/// when no such pair of faces exists.
|
||||
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
glue_impl(a, b, false)
|
||||
}
|
||||
|
||||
/// Glue every certified start cap, retaining each tool's original contact tolerance.
|
||||
pub(crate) fn glue_all_caps(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
glue_impl(a, b, true)
|
||||
}
|
||||
|
||||
fn glue_impl(a: &Body, b: &Body, all_caps: bool) -> Option<Result<Body>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
|
||||
let mid = |e: &mt::Edge| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
@@ -1205,14 +1213,40 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
|
||||
// pair left behind would stay inside the result as a zero-thickness slit (issue #616).
|
||||
let (pa, pb) = (planes(a), planes(b));
|
||||
let mut tool_sizes = HashMap::new();
|
||||
if all_caps {
|
||||
for shell in b.solid.boundaries() {
|
||||
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
|
||||
for vertex in shell.vertex_iter() {
|
||||
bounds.add(from_p3(vertex.point()));
|
||||
}
|
||||
let size = bounds.diagonal();
|
||||
if !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
for face in shell.face_iter() {
|
||||
tool_sizes.insert(face.id(), size);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
|
||||
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
|
||||
// B edge → the matching A edge; B vertex → the matching A vertex.
|
||||
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
|
||||
if all_caps && pa.len().saturating_mul(pb.len()) > 2_000_000 {
|
||||
return None;
|
||||
}
|
||||
for (ia, x) in &pa {
|
||||
for (ib, y) in &pb {
|
||||
if all_caps
|
||||
&& !matches!(b.face_sources().and_then(|sources| sources.get(*ib)), Some(Some(crate::FaceSource::ExtrudeCap { high: false, .. })))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let tol = if all_caps { (a.size().max(*tool_sizes.get(&fb_list.get(*ib)?.id())?) * 1e-7).max(1e-9) * 10.0 } else { tol };
|
||||
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
|
||||
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
|
||||
continue;
|
||||
}
|
||||
@@ -1264,6 +1298,14 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
glued_b.push(*ib);
|
||||
}
|
||||
}
|
||||
if all_caps
|
||||
&& b.face_sources()?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, source)| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. })) && !glued_b.contains(&index))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if glued_a.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,10 @@ pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
|
||||
pub use offset::with_same_surface;
|
||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, half_space, join_apart, split_by_plane, transform, transform_matrix, union_apart};
|
||||
pub use ops::{
|
||||
BoolOp, boolean, can_join_all_touching_faces, can_join_touching_faces, compound_disjoint_extrusions, half_space, is_planar, join_apart,
|
||||
split_by_plane, transform, transform_matrix, union_apart,
|
||||
};
|
||||
pub use polybool::{faceted_boolean, planar_boolean};
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
|
||||
|
||||
+253
-8
@@ -23,12 +23,167 @@ pub enum BoolOp {
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Exact planar Booleans need no sampled enlargement of their tool footprint.
|
||||
pub fn is_planar(body: &Body) -> bool {
|
||||
if body.is_mesh() || body.face_count() > 16_384 {
|
||||
return false;
|
||||
}
|
||||
guard("planar body", || Ok(body.solid.face_iter().all(|face| matches!(face.oriented_surface(), mt::Surface::Plane(_))))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Batch separated extrusion tools without repeatedly unioning an ever-growing target.
|
||||
/// Construction caps provide conservative bounds, including the full circle of each arc;
|
||||
/// unsupported or touching tools retain the caller's sequential Boolean path.
|
||||
pub fn compound_disjoint_extrusions(tools: &[Body]) -> Result<Option<Body>> {
|
||||
if tools.len() < 2 || tools.len() > 1024 {
|
||||
return Ok(None);
|
||||
}
|
||||
guard("compound extrusion tools", || {
|
||||
let mut bounds = Vec::with_capacity(tools.len());
|
||||
let mut corners = Vec::with_capacity(tools.len());
|
||||
let mut axes = Vec::with_capacity(tools.len());
|
||||
let mut remaining = 16_384usize;
|
||||
for tool in tools {
|
||||
let Some(sources) = tool.face_sources() else { return Ok(None) };
|
||||
if sources.len() != tool.face_count() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bb = solidcraft_geom::Aabb3::EMPTY;
|
||||
let mut points = Some(Vec::new());
|
||||
let mut directions = Vec::new();
|
||||
let mut caps = 0;
|
||||
for source in sources {
|
||||
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
|
||||
remaining = left;
|
||||
match source {
|
||||
Some(crate::FaceSource::ExtrudeCap { region, plane, high }) => {
|
||||
caps += 1;
|
||||
// The bottom support is outward-facing, but its region retains
|
||||
// the original sketch coordinates (see build::extrude).
|
||||
let plane = if *high { *plane } else { solidcraft_geom::Plane { y: -plane.y, ..*plane } };
|
||||
for segment in ®ion.outer.segs {
|
||||
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
|
||||
remaining = left;
|
||||
match *segment {
|
||||
solidcraft_geom::Seg2::Line { a, b } => {
|
||||
let (a, b) = (plane.to_world(a), plane.to_world(b));
|
||||
bb.add(a);
|
||||
bb.add(b);
|
||||
if let Some(points) = points.as_mut() {
|
||||
points.extend([a, b]);
|
||||
}
|
||||
if let Some(axis) = (b - a).cross(plane.normal()).normalized() {
|
||||
directions.push(axis);
|
||||
}
|
||||
}
|
||||
solidcraft_geom::Seg2::Arc { center, radius, .. } => {
|
||||
points = None;
|
||||
for x in [-radius, radius] {
|
||||
for y in [-radius, radius] {
|
||||
bb.add(plane.to_world(center + solidcraft_geom::Vec2::new(x, y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::FaceSource::ExtrudeSide { .. }) => {}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
if caps != 2 || bb.is_empty() || !bb.min.is_finite() || !bb.max.is_finite() {
|
||||
return Ok(None);
|
||||
}
|
||||
bounds.push(bb);
|
||||
corners.push(points);
|
||||
axes.push(directions);
|
||||
}
|
||||
let mut remaining_separation_work = 16_000_000usize;
|
||||
for (i, a) in bounds.iter().enumerate() {
|
||||
for (j, b) in bounds.iter().enumerate().skip(i + 1) {
|
||||
let gap = (a.diagonal().max(b.diagonal()) * 1e-6).max(1e-7);
|
||||
let separated = a.max.x + gap < b.min.x
|
||||
|| b.max.x + gap < a.min.x
|
||||
|| a.max.y + gap < b.min.y
|
||||
|| b.max.y + gap < a.min.y
|
||||
|| a.max.z + gap < b.min.z
|
||||
|| b.max.z + gap < a.min.z;
|
||||
if separated {
|
||||
continue;
|
||||
}
|
||||
let (Some(Some(x)), Some(Some(y))) = (corners.get(i), corners.get(j)) else { return Ok(None) };
|
||||
let Some(origin) = x.first() else { return Ok(None) };
|
||||
// Every straight extrusion lies inside the hull of its cap corners.
|
||||
// A separating cap-edge axis therefore proves clearance even when
|
||||
// rotated tools' world-coordinate boxes overlap. Arcs retain the box test.
|
||||
let range = |points: &[Vec3], axis: Vec3| {
|
||||
points.iter().map(|p| (*p - *origin).dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
|
||||
};
|
||||
let rounding = origin.x.abs().max(origin.y.abs()).max(origin.z.abs()) * f64::EPSILON * 128.0;
|
||||
let separated = axes.get(i).into_iter().flatten().chain(axes.get(j).into_iter().flatten()).any(|axis| {
|
||||
let Some(left) = remaining_separation_work.checked_sub(x.len().saturating_add(y.len())) else { return false };
|
||||
remaining_separation_work = left;
|
||||
let (a, b) = (range(x, *axis), range(y, *axis));
|
||||
a.1 + gap + rounding < b.0 || b.1 + gap + rounding < a.0
|
||||
});
|
||||
if !separated {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sources: std::collections::HashMap<_, _> = tools
|
||||
.iter()
|
||||
.flat_map(|tool| {
|
||||
tool.solid
|
||||
.face_iter()
|
||||
.zip(tool.face_sources().unwrap_or_default())
|
||||
.map(|(face, source)| (face.id(), (face.orientation(), source.clone())))
|
||||
})
|
||||
.collect();
|
||||
let operands: Vec<_> = tools.iter().collect();
|
||||
let shells = tools.iter().flat_map(|tool| tool.solid.boundaries().clone()).collect();
|
||||
let solid = Solid::try_new(shells).map_err(|error| KernelError::Failed(format!("extrusion compound: {error}")))?;
|
||||
let faces = tools
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(operand, tool)| (0..tool.face_count()).map(move |face| record::from_face(operand, face, crate::history::Change::Kept)))
|
||||
.collect();
|
||||
let mut edges = HashMap::new();
|
||||
for (operand, tool) in tools.iter().enumerate() {
|
||||
edges.extend(record::edge_origins(&tool.solid, operand));
|
||||
}
|
||||
record::build(solid, &record::counts(&operands), Some(faces), Some(&edges)).map(|mut body| {
|
||||
body.regularization_protected = tools.iter().any(|tool| tool.regularization_protected);
|
||||
// Only unchanged face identities carry these transient tool witnesses.
|
||||
// No whole-compound extrusion/producer certificate is created.
|
||||
let mapped = body
|
||||
.solid
|
||||
.face_iter()
|
||||
.map(|face| {
|
||||
sources.get(&face.id()).filter(|(orientation, _)| *orientation == face.orientation()).and_then(|(_, source)| source.clone())
|
||||
})
|
||||
.collect();
|
||||
Some(body.with_face_sources(Some(std::sync::Arc::new(mapped))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a union can sew two matching planar faces without intersecting them.
|
||||
/// Callers can retain the exact touching tool instead of extending it into material.
|
||||
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
|
||||
}
|
||||
|
||||
/// Check all separated extrusion starts in one construction, without rebuilding
|
||||
/// the unchanged target once for each face. Partial seams keep the ordinary path.
|
||||
pub fn can_join_all_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
if a.solid.boundaries().len() != 1 || !(2..=1024).contains(&b.solid.boundaries().len()) || a.face_count().max(b.face_count()) > 16_384 {
|
||||
return false;
|
||||
}
|
||||
guard("check extrusion seams", || Ok(crate::coplanar::glue_all_caps(a, b).is_some_and(|result| result.is_ok()))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||
/// back) leaves the geometry unchanged but changes those rays.
|
||||
@@ -238,6 +393,97 @@ mod shortcut_tests {
|
||||
assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_extrusion_bounds_use_exact_cap_separation_and_keep_sources() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let rectangle = |angle: f64, offset: f64| {
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
Region2 {
|
||||
outer: Loop2::polygon(
|
||||
&[(-2., -0.5), (2., -0.5), (2., 0.5), (-2., 0.5)].map(|(x, y)| Vec2::new(x * c - (y + offset) * s, x * s + (y + offset) * c)),
|
||||
),
|
||||
holes: vec![],
|
||||
}
|
||||
};
|
||||
for offset in [1.2, 1., 0.8] {
|
||||
let tools = crate::extrude(&Plane::XY, &[rectangle(0.7, 0.), rectangle(0.7, offset)], 0., 2.).unwrap();
|
||||
let compound = compound_disjoint_extrusions(&tools).unwrap();
|
||||
assert_eq!(compound.is_some(), offset > 1.);
|
||||
if let Some(compound) = compound {
|
||||
let sources = compound.face_sources().unwrap();
|
||||
assert_eq!(sources.len(), compound.face_count());
|
||||
assert_eq!(sources.iter().filter(|source| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. }))).count(), 2);
|
||||
assert!(compound.validity().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_contact_test_matches_individual_tolerances() {
|
||||
let target = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 3.).unwrap();
|
||||
let tools = [
|
||||
crate::box_solid(Vec3::new(-1., -1., 3.), Vec3::new(1., 1., 5.)).unwrap(),
|
||||
crate::box_solid(Vec3::new(-1., -1., 3.002), Vec3::new(1., 1., 5.)).unwrap(),
|
||||
crate::sphere(Vec3::new(20., 0., 1.), 1.).unwrap(),
|
||||
];
|
||||
let expected = tools.iter().map(|tool| target.meets(tool).unwrap()).collect::<Vec<_>>();
|
||||
assert_eq!(target.meets_each(&tools).unwrap(), expected);
|
||||
assert!(target.meets_each(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_span_does_not_relax_full_face_seam_tolerance() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let mut target = crate::box_solid(Vec3::new(0., 0., -3.), Vec3::new(100., 2., -2.)).unwrap();
|
||||
for x in [0., 98.] {
|
||||
let boss = crate::box_solid(Vec3::new(x, 0., -2.), Vec3::new(x + 2., 2., 0.)).unwrap();
|
||||
target = boolean(&target, &boss, BoolOp::Union).unwrap().unwrap();
|
||||
}
|
||||
let regions: Vec<_> = [0., 98.]
|
||||
.into_iter()
|
||||
.map(|x| Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(x, 0.), Vec2::new(x + 2., 0.), Vec2::new(x + 2., 2.), Vec2::new(x, 2.)]),
|
||||
holes: vec![],
|
||||
})
|
||||
.collect();
|
||||
let tools = crate::extrude(&Plane::XY, ®ions, 0.000_105, 200.).unwrap();
|
||||
assert!(tools.iter().all(|tool| !can_join_touching_faces(&target, tool)));
|
||||
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
|
||||
assert!(!can_join_all_touching_faces(&target, &compound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrusion_compound_requires_separated_construction_bounds() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let rectangle = |x: f64| Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(x, 0.0), Vec2::new(x + 2.0, 0.0), Vec2::new(x + 2.0, 2.0), Vec2::new(x, 2.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let plane = Plane::from_normal(Vec3::new(100.0, -20.0, 8.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
|
||||
let tools = crate::extrude(&plane, &[rectangle(0.0), rectangle(20.0)], -1.0, 5.0).unwrap();
|
||||
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
|
||||
assert_eq!(compound.shell_count(), 2);
|
||||
assert_eq!(compound.history().unwrap().operands, 2);
|
||||
assert!((crate::measure(&compound).unwrap().volume - 48.0).abs() < 1e-8);
|
||||
assert!(compound.validity().is_empty());
|
||||
for x in [1.0, 2.0, 2.000_000_1] {
|
||||
let touching = crate::extrude(&solidcraft_geom::Plane::XY, &[rectangle(0.0), rectangle(x)], 0.0, 5.0).unwrap();
|
||||
assert!(compound_disjoint_extrusions(&touching).unwrap().is_none());
|
||||
}
|
||||
let unsupported = [crate::sphere(Vec3::ZERO, 1.0).unwrap(), crate::sphere(Vec3::new(10.0, 0.0, 0.0), 1.0).unwrap()];
|
||||
assert!(compound_disjoint_extrusions(&unsupported).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrusion_compound_includes_arc_extrema_between_seam_vertices() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let circle = |x| Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 2.0), holes: vec![] };
|
||||
let separated = crate::extrude(&Plane::XY, &[circle(0.0), circle(8.0)], 0.0, 5.0).unwrap();
|
||||
assert!(compound_disjoint_extrusions(&separated).unwrap().is_some());
|
||||
let overlapping = crate::extrude(&Plane::XY, &[circle(0.0), circle(3.0)], 0.0, 5.0).unwrap();
|
||||
assert!(compound_disjoint_extrusions(&overlapping).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
|
||||
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
|
||||
@@ -756,14 +1002,13 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
|
||||
}
|
||||
}
|
||||
// Bodies touching along one whole face (a part joined with its mirror image).
|
||||
if op == BoolOp::Union
|
||||
&& a.solid.boundaries().len() == 1
|
||||
&& b.solid.boundaries().len() == 1
|
||||
&& let Some(Ok(body)) = crate::coplanar::glue(a, b)
|
||||
{
|
||||
let (ok, _) = check(&body);
|
||||
if ok {
|
||||
return Ok(Some(body));
|
||||
if op == BoolOp::Union && a.solid.boundaries().len() == 1 {
|
||||
let glued = if b.solid.boundaries().len() == 1 { crate::coplanar::glue(a, b) } else { crate::coplanar::glue_all_caps(a, b) };
|
||||
if let Some(Ok(body)) = glued {
|
||||
let (ok, _) = check(&body);
|
||||
if ok {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
|
||||
|
||||
+115
-21
@@ -181,8 +181,8 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
|
||||
remaining_pairs = remaining_pairs
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
|
||||
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|
||||
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
|
||||
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(closest_param(t, *p)).dist(*p) <= 1e-7)
|
||||
|| [t.start(), t.end()].iter().any(|p| s.point_at(closest_param(s, *p)).dist(*p) <= 1e-7);
|
||||
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
|
||||
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
|
||||
}
|
||||
@@ -765,7 +765,7 @@ fn heights_of(b: &Body, plane: &Plane) -> (f64, f64) {
|
||||
/// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one
|
||||
/// circle, `s`'s ends and middle on `t`.
|
||||
fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool {
|
||||
let on = |p: Vec2| t.point_at(t.closest_param(p)).dist(p) < tol;
|
||||
let on = |p: Vec2| t.point_at(closest_param(t, p)).dist(p) < tol;
|
||||
match (*s, *t) {
|
||||
(Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()),
|
||||
(Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => {
|
||||
@@ -840,7 +840,7 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in pts {
|
||||
let (u, v) = (s.closest_param(p), t.closest_param(p));
|
||||
let (u, v) = (closest_param(s, p), closest_param(t, p));
|
||||
if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol {
|
||||
out.push((u, v));
|
||||
}
|
||||
@@ -871,7 +871,7 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
|
||||
}
|
||||
}
|
||||
// A point on the loop keeps the half-open answer.
|
||||
if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) {
|
||||
if lp.segs.iter().any(|s| s.point_at(closest_param(s, p)).dist(p) < scale * 1e-9) {
|
||||
return inside_loop_ray(lp, p);
|
||||
}
|
||||
let grazes = |q: Vec2| {
|
||||
@@ -991,12 +991,43 @@ fn inside(regions: &[Region2], p: Vec2) -> bool {
|
||||
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
|
||||
}
|
||||
|
||||
/// Call only after proving the point is at least `tol` from the other boundary.
|
||||
/// This sub-tolerance interior probe cannot cross that boundary, and avoids casting
|
||||
/// exactly through fitted arc/line endpoints whose heights differ by roundoff.
|
||||
fn inside_clear(regions: &[Region2], point: Vec2, tol: f64) -> bool {
|
||||
inside(regions, point + Vec2::new(tol * 0.173, tol * 0.317))
|
||||
}
|
||||
|
||||
/// Analytic projections avoid iterative searches at every planar boundary probe.
|
||||
/// Other curve types keep the shared solver introduced on main.
|
||||
fn closest_param(segment: &Seg2, point: Vec2) -> f64 {
|
||||
match *segment {
|
||||
Seg2::Line { a, b } => {
|
||||
let direction = b - a;
|
||||
let length2 = direction.dot(direction);
|
||||
if length2 > 1e-300 && length2.is_finite() { ((point - a).dot(direction) / length2).clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 => {
|
||||
let offset = point - center;
|
||||
let delta = ((offset.y.atan2(offset.x) - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
|
||||
if delta <= sweep.abs() {
|
||||
(delta / sweep.abs()).clamp(0.0, 1.0)
|
||||
} else if segment.start().dist(point) <= segment.end().dist(point) {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
_ => segment.closest_param(point),
|
||||
}
|
||||
}
|
||||
|
||||
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
|
||||
regions
|
||||
.iter()
|
||||
.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
|
||||
.flat_map(|l| l.segs.iter())
|
||||
.map(|s| s.point_at(s.closest_param(p)).dist(p))
|
||||
.map(|s| s.point_at(closest_param(s, p)).dist(p))
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
@@ -1043,7 +1074,7 @@ fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
|
||||
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
|
||||
/// segment is within the tolerance of `p` then).
|
||||
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
|
||||
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
|
||||
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(closest_param(s, p)).dist(p)).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
|
||||
@@ -1052,7 +1083,7 @@ fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Optio
|
||||
.iter()
|
||||
.zip(boxes)
|
||||
.filter(|(_, b)| box_holds(**b, p))
|
||||
.map(|(s, _)| (s, s.closest_param(p)))
|
||||
.map(|(s, _)| (s, closest_param(s, p)))
|
||||
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
|
||||
s.derivative(t).normalized()
|
||||
}
|
||||
@@ -1193,7 +1224,7 @@ fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 {
|
||||
if hi < 1.0 - 1e-12 {
|
||||
// Splitting a conic renormalises its weights, which moves its parameters: the second
|
||||
// split is found on the piece.
|
||||
let at = s.closest_param(src.point_at(hi));
|
||||
let at = closest_param(&s, src.point_at(hi));
|
||||
if at < 1.0 - 1e-12 {
|
||||
s = s.split_at(at).0;
|
||||
}
|
||||
@@ -1304,7 +1335,7 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
|
||||
} else if p.dist(src.end()) < near {
|
||||
1.0
|
||||
} else {
|
||||
src.closest_param(p)
|
||||
closest_param(src, p)
|
||||
}
|
||||
};
|
||||
let (t0, t1) = (at(seg.start()), at(last.end()));
|
||||
@@ -1340,12 +1371,12 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
|
||||
(Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => {
|
||||
src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a))))
|
||||
}
|
||||
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => {
|
||||
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a))))
|
||||
}
|
||||
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => {
|
||||
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a))))
|
||||
}
|
||||
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f)
|
||||
.zip(carrier(&s))
|
||||
.and_then(|(x, c)| meet(x, t1, &c, closest_param(&c, joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))),
|
||||
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => src(f)
|
||||
.zip(carrier(&s))
|
||||
.and_then(|(x, c)| meet(x, t0, &c, closest_param(&c, joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))),
|
||||
_ => None,
|
||||
};
|
||||
// No crossing to refine (or none found): both end where the curve does.
|
||||
@@ -1449,7 +1480,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
continue;
|
||||
}
|
||||
for p in [t.start(), t.end()] {
|
||||
let u = s.closest_param(p);
|
||||
let u = closest_param(s, p);
|
||||
if s.point_at(u).dist(p) < tol {
|
||||
events += 1;
|
||||
if events > MAX_PLANAR_EVENTS {
|
||||
@@ -1466,7 +1497,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
continue;
|
||||
}
|
||||
for p in [s.start(), s.end()] {
|
||||
let v = t.closest_param(p);
|
||||
let v = closest_param(t, p);
|
||||
if t.point_at(v).dist(p) < tol {
|
||||
events += 1;
|
||||
if events > MAX_PLANAR_EVENTS {
|
||||
@@ -1522,7 +1553,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
BoolOp::Cut => !same,
|
||||
}
|
||||
} else {
|
||||
let ins = inside(&b, m);
|
||||
let ins = inside_clear(&b, m, tol);
|
||||
match op {
|
||||
BoolOp::Union | BoolOp::Cut => !ins,
|
||||
BoolOp::Intersect => ins,
|
||||
@@ -1548,7 +1579,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
if near_dist(&sa, &box_a, m) < tol {
|
||||
continue;
|
||||
}
|
||||
let ins = inside(&a, m);
|
||||
let ins = inside_clear(&a, m, tol);
|
||||
if kept.len() >= MAX_PLANAR_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
@@ -1712,7 +1743,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
||||
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
|
||||
return None;
|
||||
}
|
||||
let t = seg.closest_param(*p);
|
||||
let t = closest_param(seg, *p);
|
||||
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
@@ -1942,6 +1973,13 @@ fn sew_connected_sections(
|
||||
ends.push(u);
|
||||
}
|
||||
}
|
||||
// Welding above keeps its original encounter order. Side splitting needs
|
||||
// only the distinct stations, whose resulting parameters are sorted anyway.
|
||||
// Deduplicate in place, with bounded sorting work and no extra point list.
|
||||
if pts.len() <= MAX_PLANAR_SEGMENTS * 8 {
|
||||
pts.sort_unstable_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
pts.dedup_by_key(|p| (p.x.to_bits(), p.y.to_bits()));
|
||||
}
|
||||
// A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from
|
||||
// its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs
|
||||
// are left as they were: such a piece of one fails the sewing, as before.)
|
||||
@@ -2518,6 +2556,62 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
|
||||
#[cfg(test)]
|
||||
mod planar_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
|
||||
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
|
||||
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
for epsilon in [-4e-14, 4e-14] {
|
||||
let b = Region2 {
|
||||
outer: Loop2 {
|
||||
segs: vec![
|
||||
line((1.15, epsilon), (-1.15, -epsilon)),
|
||||
Seg2::Arc { center: Vec2::new(-1.35, epsilon), radius: 0.2, start: 0., sweep: -std::f64::consts::FRAC_PI_2 },
|
||||
line((-1.35, -0.2), (-2.4, -0.2)),
|
||||
line((-2.4, -0.2), (-2.4, -1.)),
|
||||
line((-2.4, -1.), (2.4, -1.)),
|
||||
line((2.4, -1.), (2.4, -0.2)),
|
||||
line((2.4, -0.2), (1.35, -0.2)),
|
||||
Seg2::Arc {
|
||||
center: Vec2::new(1.35, -epsilon),
|
||||
radius: 0.2,
|
||||
start: -std::f64::consts::FRAC_PI_2,
|
||||
sweep: -std::f64::consts::FRAC_PI_2,
|
||||
},
|
||||
],
|
||||
},
|
||||
holes: vec![],
|
||||
};
|
||||
let result = planar_regions_boolean(std::slice::from_ref(&a), std::slice::from_ref(&b), BoolOp::Union, 1e-5).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].outer.is_closed(1e-4));
|
||||
assert!((result[0].area() - a.area() - b.area()).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn analytic_projection_keeps_clamped_lines_and_wrapped_arcs() {
|
||||
let line = Seg2::Line { a: Vec2::new(2., 3.), b: Vec2::new(5., 7.) };
|
||||
for (point, expected) in [(Vec2::new(-1., -1.), 0.), (Vec2::new(3.5, 5.), 0.5), (Vec2::new(8., 11.), 1.)] {
|
||||
assert!((closest_param(&line, point) - expected).abs() < 1e-12);
|
||||
}
|
||||
for start in [-7., -0.5, 3., 7.] {
|
||||
for sweep in [-std::f64::consts::TAU, -5., -0.2, 0.2, 5., std::f64::consts::TAU] {
|
||||
let arc = Seg2::Arc { center: Vec2::new(2., -3.), radius: 4., start, sweep };
|
||||
for index in 0..128 {
|
||||
let angle = index as f64 * std::f64::consts::TAU / 128.;
|
||||
let point = Vec2::new(2., -3.) + Vec2::new(angle.cos(), angle.sin()) * 6.;
|
||||
let nearest = arc.point_at(closest_param(&arc, point));
|
||||
// Includes both signs, sweeps over pi, seam crossings and endpoint clamps.
|
||||
for sample in 0..=128 {
|
||||
assert!(nearest.dist(point) <= arc.point_at(sample as f64 / 128.).dist(point) + 1e-10);
|
||||
}
|
||||
}
|
||||
for t in [0., 0.25, 0.5, 0.75, 1.] {
|
||||
let point = arc.point_at(t);
|
||||
assert!(arc.point_at(closest_param(&arc, point)).dist(point) < 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
|
||||
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
|
||||
}
|
||||
|
||||
@@ -3760,7 +3760,36 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
if !sketch.is_null() {
|
||||
out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
|
||||
}
|
||||
// Each planar body face extrudes on its own.
|
||||
// Coplanar faces of one body share one sketch and Boolean. Mixed selections
|
||||
// retain their original order and explicit face-body scope.
|
||||
let state = s.world_state();
|
||||
let mut group: Option<(String, solidcraft_engine::geom::Plane, Vec<Value>)> = None;
|
||||
let mut coplanar = true;
|
||||
for pick in sels(d, 0) {
|
||||
let Sel::Face { body, index, point } = pick else { continue };
|
||||
let Some(source) = state.body(body) else { return Err("selected body disappeared".into()) };
|
||||
let Some(plane) = source.body.planar_face(*index) else {
|
||||
coplanar = false;
|
||||
break;
|
||||
};
|
||||
let tolerance = (source.body.size() * 1e-9).max(1e-8);
|
||||
if let Some((name, base, points)) = &mut group {
|
||||
if name != body || base.normal().dot(plane.normal()) <= 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
|
||||
coplanar = false;
|
||||
break;
|
||||
}
|
||||
points.push(pt(*point));
|
||||
} else {
|
||||
group = Some((body.clone(), plane, vec![pt(*point)]));
|
||||
}
|
||||
}
|
||||
if coplanar
|
||||
&& let Some((body, _, points)) = group
|
||||
&& points.len() > 1
|
||||
{
|
||||
out.push(("solid.extrude".to_string(), with(json!({"faces": points, "face_body": body}))));
|
||||
return Ok(out);
|
||||
}
|
||||
for pick in sels(d, 0) {
|
||||
if let Sel::Face { body, point, .. } = pick {
|
||||
out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body}))));
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Straight extrusion feedback from existing display triangles, without a scratch design.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_engine::{
|
||||
Sel,
|
||||
doc::Document,
|
||||
geom::{Aabb3, Seg2, Vec3},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
SolveApp,
|
||||
gpu::GpuScene,
|
||||
preview::{Built, Colors},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StraightPreview {
|
||||
cached: Option<Cached>,
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
document: Arc<Document>,
|
||||
revision: u64,
|
||||
inputs: Vec<Sel>,
|
||||
surfaces: Vec<Surface>,
|
||||
bounds: Aabb3,
|
||||
}
|
||||
struct Surface {
|
||||
normal: Vec3,
|
||||
triangles: Vec<[Vec3; 3]>,
|
||||
edges: Vec<[Vec3; 2]>,
|
||||
}
|
||||
|
||||
impl StraightPreview {
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.cached = None;
|
||||
}
|
||||
pub(crate) fn compute(&mut self, app: &SolveApp, commands: &[(String, Value)], colors: Colors) -> Option<Result<Built, String>> {
|
||||
let [(id, params)] = commands else { return None };
|
||||
let params = match id.as_str() {
|
||||
"solid.extrude" => params,
|
||||
"timeline.redefine" if params.get("command").and_then(Value::as_str) == Some("solid.extrude") => params.get("params")?,
|
||||
_ => return None,
|
||||
};
|
||||
// A newly opened STEP is now a component. Its single identity placement
|
||||
// has the same authoring coordinates; other placements keep Engine preview.
|
||||
if app.session.active_component != 0 || app.session.active_occurrence_path.is_some() {
|
||||
let path = app.session.active_occurrence_path.as_ref()?;
|
||||
let placed = app.session.occurrence_placement(path).ok()?;
|
||||
if path.len() != 1
|
||||
|| placed.component != app.session.active_component
|
||||
|| placed.world != solidcraft_engine::doc::IDENTITY
|
||||
|| app.session.doc.occurrences.iter().filter(|o| o.component == placed.component).count() != 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if app.session.doc.units != "mm"
|
||||
|| ["distance2", "start_offset", "from", "to", "to_offset", "thin"].iter().any(|key| params.get(*key).is_some())
|
||||
|| params.get("through_all").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let direction = params.get("direction").and_then(Value::as_str).unwrap_or("positive");
|
||||
if !matches!(direction, "positive" | "negative") {
|
||||
return None;
|
||||
}
|
||||
if let Some(taper) = params.get("taper") {
|
||||
let expression = taper.as_str().map(str::to_owned).unwrap_or_else(|| taper.to_string());
|
||||
if app.session.doc.eval(&expression, solidcraft_engine::doc::expr::Kind::Angle).ok()?.abs() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let distance = params.get("distance")?;
|
||||
let expression = distance.as_str().map(str::to_owned).unwrap_or_else(|| distance.to_string());
|
||||
let mut distance = app.session.doc.eval(&expression, solidcraft_engine::doc::expr::Kind::Length).ok()?;
|
||||
if direction == "negative" {
|
||||
distance = -distance;
|
||||
}
|
||||
if !distance.is_finite() || distance.abs() < 1e-9 || distance.abs() > 1e6 {
|
||||
return None;
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).unwrap_or("new");
|
||||
if !matches!(operation, "new" | "join" | "cut" | "intersect") {
|
||||
return None;
|
||||
}
|
||||
let inputs = &app.dialog.as_ref()?.inputs.first()?.items;
|
||||
if inputs.is_empty() || inputs.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.cached
|
||||
.as_ref()
|
||||
.is_none_or(|cache| cache.revision != app.session.revision || !Arc::ptr_eq(&cache.document, &app.session.doc) || cache.inputs != *inputs)
|
||||
{
|
||||
self.cached = prepare(app, inputs);
|
||||
}
|
||||
let cache = self.cached.as_ref()?;
|
||||
let mut scene = GpuScene::default();
|
||||
let mut bounds = cache.bounds;
|
||||
let color = if operation == "cut" {
|
||||
colors.removed
|
||||
} else if operation == "intersect" {
|
||||
[colors.added[0], colors.added[1], colors.added[2], colors.removed[3]]
|
||||
} else {
|
||||
colors.added
|
||||
};
|
||||
let triangle = |scene: &mut GpuScene, points: [Vec3; 3], normal: Vec3| {
|
||||
for point in points {
|
||||
if matches!(operation, "cut" | "intersect") {
|
||||
scene.xray_tri(point.to_f32(), normal.to_f32(), color);
|
||||
} else {
|
||||
scene.tri(point.to_f32(), normal.to_f32(), color);
|
||||
}
|
||||
}
|
||||
};
|
||||
for surface in &cache.surfaces {
|
||||
let shift = surface.normal * distance;
|
||||
let (low, high) = if distance > 0. { (Vec3::ZERO, shift) } else { (shift, Vec3::ZERO) };
|
||||
for &[a, b, c] in &surface.triangles {
|
||||
triangle(&mut scene, [a + low, c + low, b + low], -surface.normal);
|
||||
triangle(&mut scene, [a + high, b + high, c + high], surface.normal);
|
||||
for point in [a + low, b + low, c + low, a + high, b + high, c + high] {
|
||||
bounds.add(point);
|
||||
}
|
||||
}
|
||||
let edge_color = if operation == "cut" { [color[0], color[1], color[2], 255] } else { colors.edge };
|
||||
for &[a, b] in &surface.edges {
|
||||
let normal = (b - a).cross(surface.normal).normalized()?;
|
||||
triangle(&mut scene, [a + low, b + low, b + high], normal);
|
||||
triangle(&mut scene, [a + low, b + high, a + high], normal);
|
||||
for (a, b) in [(a + low, b + low), (a + high, b + high), (a + low, a + high)] {
|
||||
scene.line(a.to_f32(), b.to_f32(), edge_color, 1.3, operation != "join");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Built {
|
||||
scene,
|
||||
sketches: Vec::new(),
|
||||
applied: None,
|
||||
stopped: None,
|
||||
tapered: Vec::new(),
|
||||
replaced: Vec::new(),
|
||||
error: crate::preview::extrusion_target_error(&app.session, commands, &app.session.model.state()),
|
||||
bounds,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(app: &SolveApp, inputs: &[Sel]) -> Option<Cached> {
|
||||
let state = app.session.world_state();
|
||||
let mut surfaces = Vec::new();
|
||||
let mut budget = 65_536usize;
|
||||
if inputs.iter().all(|input| matches!(input, Sel::Profile { .. })) {
|
||||
let Sel::Profile { sketch: owner, .. } = inputs.first()? else { return None };
|
||||
if inputs.iter().any(|input| !matches!(input,Sel::Profile {sketch,..} if sketch==owner)) {
|
||||
return None;
|
||||
}
|
||||
for input in inputs {
|
||||
let Sel::Profile { sketch, index } = input else { return None };
|
||||
let sketch = state.sketch(*sketch)?;
|
||||
let region = &sketch.profiles.get(*index)?.region;
|
||||
let loops = std::iter::once(®ion.outer).chain(®ion.holes);
|
||||
if loops.clone().flat_map(|loop_| &loop_.segs).any(|segment| !matches!(segment, Seg2::Line { .. })) {
|
||||
return None;
|
||||
}
|
||||
let triangles = crate::sketch_display::fill_triangles(region, &mut budget)
|
||||
.into_iter()
|
||||
.map(|triangle| triangle.map(|point| sketch.plane.to_world(point)))
|
||||
.collect::<Vec<_>>();
|
||||
if triangles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let edges = std::iter::once((®ion.outer, true))
|
||||
.chain(region.holes.iter().map(|hole| (hole, false)))
|
||||
.flat_map(|(loop_, outer)| {
|
||||
let reverse = (loop_.signed_area() > 0.) != outer;
|
||||
loop_.segs.iter().map(move |segment| {
|
||||
let pair = [sketch.plane.to_world(segment.start()), sketch.plane.to_world(segment.end())];
|
||||
if reverse { [pair[1], pair[0]] } else { pair }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
surfaces.push(Surface { normal: sketch.plane.normal(), triangles, edges });
|
||||
}
|
||||
} else if inputs.iter().all(|input| matches!(input, Sel::Face { .. })) {
|
||||
// One pass over each selected body's triangles, rather than one full scan per pick.
|
||||
for body in &state.bodies {
|
||||
let mut selected = HashMap::new();
|
||||
for input in inputs {
|
||||
if let Sel::Face { body: name, index, point } = input
|
||||
&& name == &body.name
|
||||
{
|
||||
let face = u32::try_from(*index).ok()?;
|
||||
if selected.contains_key(&face) {
|
||||
return None;
|
||||
}
|
||||
let normal = body.body.planar_face(*index)?.normal();
|
||||
selected.insert(face, (surfaces.len(), *point));
|
||||
surfaces.push(Surface { normal, triangles: Vec::new(), edges: Vec::new() });
|
||||
}
|
||||
}
|
||||
if selected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if let Some((index, _)) = selected.get(face) {
|
||||
budget = budget.checked_sub(1)?;
|
||||
let mut points = mesh.tri(triangle)?;
|
||||
let surface = surfaces.get_mut(*index)?;
|
||||
if (points[1] - points[0]).cross(points[2] - points[0]).dot(surface.normal) < 0. {
|
||||
points.swap(1, 2);
|
||||
}
|
||||
surface.triangles.push(points);
|
||||
}
|
||||
}
|
||||
// The command resolves picks by their point. A stale display face index
|
||||
// must fall back rather than previewing a different face.
|
||||
let tolerance = (mesh.bounds().diagonal() * 1e-8).max(1e-8);
|
||||
for (index, point) in selected.values() {
|
||||
if !surfaces.get(*index)?.triangles.iter().any(|triangle| crate::dialogs::point_tri_dist(*point, *triangle) < tolerance) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if surfaces.len() != inputs.len() {
|
||||
return None;
|
||||
}
|
||||
for surface in &mut surfaces {
|
||||
let key = |point: Vec3| [point.x, point.y, point.z].map(|coordinate| if coordinate == 0. { 0 } else { coordinate.to_bits() });
|
||||
let mut edges = HashMap::new();
|
||||
for &[a, b, c] in &surface.triangles {
|
||||
for (a, b) in [(a, b), (b, c), (c, a)] {
|
||||
let (ka, kb) = (key(a), key(b));
|
||||
let entry = edges.entry(if ka < kb { (ka, kb) } else { (kb, ka) });
|
||||
match entry {
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
entry.insert(Some([a, b]));
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut entry) => {
|
||||
entry.get().as_ref()?;
|
||||
entry.insert(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
surface.edges = edges.into_values().flatten().collect();
|
||||
if surface.triangles.is_empty() || surface.edges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
let bounds = state.bodies.iter().fold(Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh().bounds()));
|
||||
Some(Cached { document: app.session.doc.clone(), revision: app.session.revision, inputs: inputs.to_vec(), surfaces, bounds })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{dialogs::Kind, scenario::Harness};
|
||||
use serde_json::json;
|
||||
|
||||
fn mesh_volume(scene: &GpuScene) -> f64 {
|
||||
scene
|
||||
.tris
|
||||
.as_chunks::<{ crate::gpu::TRI_SIZE * 3 }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|triangle| {
|
||||
let points: Vec<_> = triangle
|
||||
.as_chunks::<{ crate::gpu::TRI_SIZE }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|vertex| {
|
||||
let numbers: Vec<_> =
|
||||
vertex.get(..12).unwrap().as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes) as f64).collect();
|
||||
Vec3::new(numbers[0], numbers[1], numbers[2])
|
||||
})
|
||||
.collect();
|
||||
points[0].dot(points[1].cross(points[2])) / 6.
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
#[test]
|
||||
fn face_mesh_feedback_reuses_cap_and_handles_both_distance_signs() {
|
||||
let mut harness = Harness::new();
|
||||
harness.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap();
|
||||
let state = harness.app.session.world_state();
|
||||
let face = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(0.001)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99))
|
||||
.unwrap();
|
||||
harness.app.session.selection = vec![Sel::Face { body: "Stock".into(), index: face.index, point: face.centroid }];
|
||||
harness.app.start("solid.extrude");
|
||||
let original = harness.app.session.doc.clone();
|
||||
let undo = harness.app.session.undo.len();
|
||||
let mut preview = StraightPreview::default();
|
||||
let colors = Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let mut pointer = None;
|
||||
for distance in [2.0_f64, -2., 3.] {
|
||||
if let Kind::Extrude { distance: value, operation, auto_op, .. } = &mut harness.app.dialog.as_mut().unwrap().kind {
|
||||
*value = distance.to_string();
|
||||
*operation = 0;
|
||||
*auto_op = false;
|
||||
}
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let scene = preview.compute(&harness.app, &commands, colors).unwrap().unwrap();
|
||||
assert!((mesh_volume(&scene.scene) - 12. * distance.abs()).abs() < 1e-5);
|
||||
let current = preview.cached.as_ref().unwrap().surfaces[0].triangles.as_ptr();
|
||||
if let Some(pointer) = pointer {
|
||||
assert_eq!(pointer, current);
|
||||
} else {
|
||||
pointer = Some(current);
|
||||
}
|
||||
}
|
||||
assert!(Arc::ptr_eq(&original, &harness.app.session.doc));
|
||||
assert_eq!(harness.app.session.undo.len(), undo);
|
||||
harness.app.run("solid.box", json!({"length":1,"width":1,"height":1,"corner":[30,0,0]})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
preview.compute(&harness.app, &commands, colors).unwrap().unwrap();
|
||||
assert!(Arc::ptr_eq(&preview.cached.as_ref().unwrap().document, &harness.app.session.doc));
|
||||
assert!(!Arc::ptr_eq(&original, &preview.cached.as_ref().unwrap().document));
|
||||
if let Sel::Face { point, .. } = &mut harness.app.dialog.as_mut().unwrap().inputs[0].items[0] {
|
||||
*point = Vec3::new(100., 100., 100.);
|
||||
}
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(preview.compute(&harness.app, &commands, colors).is_none());
|
||||
let mut commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
commands[0].1["through_all"] = json!(true);
|
||||
assert!(preview.compute(&harness.app, &commands, colors).is_none());
|
||||
commands[0].1 = json!({"distance":2,"taper":"3deg"});
|
||||
assert!(preview.compute(&harness.app, &commands, colors).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn identity_component_feedback_skips_moved_and_repeated_placements() {
|
||||
let mut h = Harness::new();
|
||||
let made = h.app.run("component.create", json!({"name":"Imported part"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap();
|
||||
let world = h.app.session.world_state();
|
||||
let face =
|
||||
world.body("Stock").unwrap().body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap();
|
||||
h.app.session.selection = vec![Sel::Face { body: "Stock".into(), index: face.index, point: face.centroid }];
|
||||
h.app.start("solid.extrude");
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let colors = Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let mut preview = StraightPreview::default();
|
||||
assert!(preview.compute(&h.app, &commands, colors).unwrap().is_ok());
|
||||
h.app.run("occurrence.move", json!({"occurrence":made["occurrence"],"translate":[20,0,0]})).unwrap();
|
||||
assert!(preview.compute(&h.app, &commands, colors).is_none());
|
||||
h.app.run("occurrence.move", json!({"occurrence":made["occurrence"],"translate":[-20,0,0]})).unwrap();
|
||||
h.app.run("occurrence.copy", json!({"component":made["component"],"translate":[30,0,0]})).unwrap();
|
||||
assert!(preview.compute(&h.app, &commands, colors).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_feedback_keeps_holes_and_skips_unsupported_curves() {
|
||||
let mut harness = Harness::new();
|
||||
harness.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
for (a, b) in [([0., 0.], [10., 10.]), ([3., 3.], [7., 7.])] {
|
||||
harness.app.run("sketch.rectangle.two_point", json!({"p0":a,"p1":b})).unwrap();
|
||||
}
|
||||
harness.app.run("sketch.finish", json!({})).unwrap();
|
||||
let sketch = harness.app.session.model.state().sketches[0].clone();
|
||||
let index = sketch.profiles.iter().position(|profile| !profile.region.holes.is_empty()).unwrap();
|
||||
harness.app.session.selection = vec![Sel::Profile { sketch: sketch.feature, index }];
|
||||
harness.app.start("solid.extrude");
|
||||
if let Kind::Extrude { distance, operation, auto_op, .. } = &mut harness.app.dialog.as_mut().unwrap().kind {
|
||||
*distance = "2".into();
|
||||
*operation = 0;
|
||||
*auto_op = false;
|
||||
}
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let scene = StraightPreview::default().compute(&harness.app, &commands, Colors::from_tokens(&crate::theme::Tokens::get())).unwrap().unwrap();
|
||||
assert!((mesh_volume(&scene.scene) - 168.).abs() < 1e-5);
|
||||
harness.app.dialog = None;
|
||||
harness.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
harness.app.run("sketch.circle.center", json!({"center":[20,0],"diameter":4})).unwrap();
|
||||
harness.app.run("sketch.finish", json!({})).unwrap();
|
||||
let feature = harness.app.session.model.state().sketches.last().unwrap().feature;
|
||||
harness.app.session.selection = vec![Sel::Profile { sketch: feature, index: 0 }];
|
||||
harness.app.start("solid.extrude");
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(StraightPreview::default().compute(&harness.app, &commands, Colors::from_tokens(&crate::theme::Tokens::get())).is_none());
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ mod apply_off_thread_tests;
|
||||
mod expression_complete;
|
||||
#[cfg(test)]
|
||||
mod extrude_edit_tests;
|
||||
mod extrude_preview;
|
||||
mod file_load;
|
||||
mod file_picker;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -6,6 +6,51 @@ use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::{Sel, Session};
|
||||
|
||||
#[test]
|
||||
fn confirming_coplanar_body_faces_uses_one_atomic_extrusion() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let parent = h.app.run("component.create", json!({"name":"Imported assembly"})).unwrap()["component"].clone();
|
||||
h.app.run("component.create", json!({"name":"Part"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":30,"width":20,"height":20,"body_name":"Plate"})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for x in [2, 10] {
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[x,2],"p1":[x+4,6]})).unwrap();
|
||||
}
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("solid.extrude", json!({"distance":6,"operation":"join"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":parent})).unwrap();
|
||||
let world = h.app.session.world_state();
|
||||
let body = world.body("Plate").unwrap();
|
||||
h.app.session.selection = body
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99) && (face.centroid.z - 26.0).abs() < 1e-8)
|
||||
.map(|face| Sel::Face { body: "Plate".into(), index: face.index, point: face.centroid })
|
||||
.collect();
|
||||
assert_eq!(h.app.session.selection.len(), 2);
|
||||
h.app.start("solid.extrude");
|
||||
if let Kind::Extrude { distance, .. } = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
*distance = "-7".into();
|
||||
}
|
||||
h.frames(3);
|
||||
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands.first().unwrap().1["faces"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(commands.first().unwrap().1["face_body"], "Plate");
|
||||
assert_eq!(commands.first().unwrap().1["operation"], "cut");
|
||||
assert!(h.app.preview.error.is_none(), "{:?}", h.app.preview.error);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "busy={}, dialog={:?}", h.app.applying.is_some(), h.app.dialog);
|
||||
assert_eq!(h.app.session.doc.features.len(), 5);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let volume = solidcraft_engine::doc::kernel::measure(&h.app.session.model.state().body("Plate").unwrap().body).unwrap().volume;
|
||||
assert!((volume - 11_968.0).abs() < 1e-5, "{volume}");
|
||||
}
|
||||
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
|
||||
@@ -98,10 +98,12 @@ pub struct PreviewState {
|
||||
pub stopped: Option<serde_json::Value>,
|
||||
/// The radius splines the previewed fillet tapered its edges with.
|
||||
pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>,
|
||||
straight: crate::extrude_preview::StraightPreview,
|
||||
}
|
||||
|
||||
impl PreviewState {
|
||||
fn clear(&mut self) {
|
||||
self.straight.clear();
|
||||
self.running = None;
|
||||
self.running_tool = false;
|
||||
self.running_stream_key = None;
|
||||
@@ -293,6 +295,14 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if pv.running.is_none() && pv.shown_key != Some(key) {
|
||||
pv.started = crate::now_ms();
|
||||
pv.running_stream_key = stream_key;
|
||||
let mut straight = std::mem::take(&mut app.preview.straight);
|
||||
let job = straight.compute(app, &cmds, colors);
|
||||
app.preview.straight = straight;
|
||||
if let Some(job) = job {
|
||||
app.preview.apply(key, job);
|
||||
app.preview.busy = false;
|
||||
return;
|
||||
}
|
||||
let scratch = app.session.scratch();
|
||||
spawn(&mut app.preview, key, scratch, cmds, colors, tool);
|
||||
}
|
||||
@@ -394,6 +404,51 @@ fn compute_tool(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn extrusion_params<'a>(id: &str, params: &'a Value) -> Option<&'a Value> {
|
||||
match id {
|
||||
"solid.extrude" => Some(params),
|
||||
"timeline.redefine" if params.get("command").and_then(Value::as_str) == Some("solid.extrude") => params.get("params"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check references without intersecting the tool with the target's geometry.
|
||||
pub(crate) fn extrusion_target_error(s: &Session, cmds: &[(String, Value)], before: &ModelState) -> Option<String> {
|
||||
for (id, params) in cmds {
|
||||
let params = extrusion_params(id, params)?;
|
||||
let op = params.get("operation").and_then(Value::as_str).unwrap_or("new");
|
||||
if op == "new" {
|
||||
continue;
|
||||
}
|
||||
let targets = params
|
||||
.get("targets")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|targets| !targets.is_empty())
|
||||
.or_else(|| params.get("participants").and_then(Value::as_array));
|
||||
let mut found = false;
|
||||
if let Some(targets) = targets.filter(|targets| !targets.is_empty()) {
|
||||
for target in targets.iter().filter_map(Value::as_str) {
|
||||
if target == "*" {
|
||||
found |= !before.bodies.is_empty();
|
||||
} else if before.body(target).is_some() {
|
||||
found = true;
|
||||
} else {
|
||||
return Some(format!("unknown body `{target}`"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
found = params.get("faces").and_then(|_| params.get("face_body")).and_then(Value::as_str).map_or_else(
|
||||
|| before.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component),
|
||||
|name| s.world_state().body(name).is_some(),
|
||||
);
|
||||
}
|
||||
if !found && matches!(op, "cut" | "intersect") {
|
||||
return Some("there is no body to cut or intersect".into());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn state_bounds(st: &ModelState) -> Aabb3 {
|
||||
st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds()))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ worse, so a kernel change shows every seed it moves. Pull requests that touch `c
|
||||
run every 8th seed; pushes to `main` and the nightly run them all. Each seed is its own test
|
||||
(`seed_00` … `seed_95`), so the CI shards and their cores share them out.
|
||||
|
||||
Explicit `platforms.<os>.seeds` records override only the listed seeds on that
|
||||
operating system; all other seeds keep the shared records and strict tolerances.
|
||||
The first four Windows records were independently reproduced on unmodified main
|
||||
`1e984c8e`; seed 94 was independently reproduced on unmodified main `c5992386`
|
||||
after the extrusion rebase. Both match the extrusion branch. Recording on a platform
|
||||
with such entries updates its changed seeds and preserves shared references.
|
||||
|
||||
To record an intended change, give the reason:
|
||||
|
||||
```
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856},
|
||||
"95": {"area":2375.340299,"bodies":1,"edges":33,"error":null,"faces":12,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","hole","cut cylinder","cut sphere","circular pattern","cut sphere"],"unsupported":1,"volume":6704.220611}
|
||||
},
|
||||
"platforms": {"windows":{"seeds":{"1":{"area":2642.450837,"bodies":1,"edges":32,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","join cylinder"],"unsupported":0,"volume":6991.569339},"23":{"area":1795.696636,"bodies":1,"edges":15,"error":null,"faces":7,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":3198.694618},"34":{"area":1086.303608,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.3653},"67":{"area":7943.46222,"bodies":2,"edges":86,"error":null,"faces":36,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.962186},"94":{"area":3470.837042,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.438772}}}},
|
||||
"notes": [
|
||||
{"reason":"First record: what each seed builds on main at 1a0100cd","seeds":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95]},
|
||||
{"reason":"seed 0 meshes differently since display tessellation plans (#805), which landed alongside these goldens (#826)","seeds":[0]},
|
||||
@@ -135,6 +136,8 @@
|
||||
{"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]},
|
||||
{"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]},
|
||||
{"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]},
|
||||
{"reason":"#1853 (a part revolve of a profile with an edge on the axis is swept a third of a turn at most per face, so the boolean can intersect its faces): seeds 28 and 95 start with a rectangle on the axis turned half a turn, now two quarter turns. Seed 28 (joined by a cylinder) has more faces for the same shape (5902.1214 -> 5902.1117 mm3, the new faces' meshes). Seed 95's hole into it failed with 'boolean Cut: no result' on the half-turn faces and now builds, so the generator's later picks move. Values from Linux CI.","seeds":[28,95]}
|
||||
{"reason":"#1853 (a part revolve of a profile with an edge on the axis is swept a third of a turn at most per face, so the boolean can intersect its faces): seeds 28 and 95 start with a rectangle on the axis turned half a turn, now two quarter turns. Seed 28 (joined by a cylinder) has more faces for the same shape (5902.1214 -> 5902.1117 mm3, the new faces' meshes). Seed 95's hole into it failed with 'boolean Cut: no result' on the half-turn faces and now builds, so the generator's later picks move. Values from Linux CI.","seeds":[28,95]},
|
||||
{"reason":"Independently reproduced on unmodified main 1e984c8e on Windows; the extrusion branch produces the same values. Shared references and tolerances remain unchanged.","platform":"windows","seeds":[1,23,34,67]},
|
||||
{"reason":"Seed 94 independently reproduced on unmodified main c5992386 on Windows after the extrusion rebase: the same steps, outcome and geometry values as the rebased branch. Shared references and tolerances remain unchanged.","platform":"windows","seeds":[94]}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user