Merge origin/main into fix/flush-pad-over-hole (keeps both round_pads_crossing_a_shallow_fall from #119 and drain_pads_flush_under_a_floor_with_a_hole, which now also checks a boss ring standing on the 1° top around the hole)
This commit is contained in:
commit
49ca7ef878
28 files changed
+1333
-94
No files matched your search
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
@@ -190,6 +195,52 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut omitted = Vec::new();
|
||||
if let Some(o) = def.as_object_mut() {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inputs: Vec<Value> = f
|
||||
.kind
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, e, k))| {
|
||||
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(usize::MAX);
|
||||
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
|
||||
@@ -104,7 +104,10 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
// Curved coincident Cut is an existing kernel limit; it must be atomic.
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
|
||||
assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("takes planar faces only"), "{error}");
|
||||
assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}");
|
||||
assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
35.0
|
||||
|
||||
@@ -436,6 +436,54 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline
|
||||
/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top
|
||||
/// edges lie on the outer walls; the cut used to fail for one order of the sections (an
|
||||
/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed").
|
||||
fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result<Value, String>) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})];
|
||||
if reverse {
|
||||
secs.reverse();
|
||||
}
|
||||
let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string());
|
||||
(s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_through_a_shelled_basin_in_either_order() {
|
||||
// What is left after the cut: the basin less the loft's part of the floor and walls
|
||||
// (integrated over the height), the same for a pad in any corner.
|
||||
let want = 41582.028;
|
||||
for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] {
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall(pad, reverse, "cut");
|
||||
assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}");
|
||||
let v = volume(&mut s);
|
||||
assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}");
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
}
|
||||
// As a new body and joined: a valid solid of positive volume either way.
|
||||
for op in ["new", "join"] {
|
||||
let mut vols = Vec::new();
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op);
|
||||
assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}");
|
||||
vols.push(volume(&mut s));
|
||||
}
|
||||
assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
@@ -96,3 +96,90 @@ fn fillet_and_chamfer_corners() {
|
||||
assert!(solved(&s));
|
||||
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
|
||||
}
|
||||
|
||||
/// The ids of the constraints driven by the model parameter `name`.
|
||||
fn driven_by(s: &Session, name: &str) -> Vec<String> {
|
||||
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned
|
||||
/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used
|
||||
/// to drop the height and leave the length on the shortened straight part, so the top came out
|
||||
/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point
|
||||
/// on both lines' extensions) and the dimensions measure to it.
|
||||
#[test]
|
||||
fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 100], [-80, 100]], "ids": ["riser", "top"]}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "riser", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "top", "mode": "horizontal"}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "riser.end"], "type": "vertical", "value": 100}));
|
||||
let height = height["param"].as_str().unwrap().to_string();
|
||||
let len = run(&mut s, "sketch.dimension", json!({"entities": ["top"], "value": 80}));
|
||||
let len = len["param"].as_str().unwrap().to_string();
|
||||
let (height_ids, len_ids) = (driven_by(&s, &height), driven_by(&s, &len));
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
|
||||
assert!(solved(&s), "{f}");
|
||||
// Both dimensions are kept (same ids) and reported as moved to the virtual corner.
|
||||
assert_eq!(driven_by(&s, &height), height_ids);
|
||||
assert_eq!(driven_by(&s, &len), len_ids);
|
||||
let corner = f["virtual_corners"][0].as_str().unwrap_or_default().to_string();
|
||||
assert!(!corner.is_empty(), "{f}");
|
||||
let moved: Vec<String> = f["moved_to_virtual_corner"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
for id in height_ids.iter().chain(&len_ids) {
|
||||
assert!(moved.contains(id), "{id}: {f}");
|
||||
}
|
||||
// The virtual corner sits where the corner was, and the part keeps its size.
|
||||
let k = sk(&s);
|
||||
let vc = k.point(k.point_index(&corner).unwrap()).unwrap();
|
||||
assert!(vc.dist(solvecraft_geom::Vec2::new(0.0, 100.0)) < 1e-6, "{vc:?}");
|
||||
let top_end = k.point(k.resolve_point("top.end").unwrap()).unwrap();
|
||||
assert!(top_end.dist(solvecraft_geom::Vec2::new(-80.0, 100.0)) < 1e-6, "{top_end:?}");
|
||||
let (lo, hi) = k.bounds().unwrap();
|
||||
assert!((hi.x - lo.x - 80.0).abs() < 1e-6 && (hi.y - lo.y - 100.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
// It is drawn with extension lines from both lines' ends to it.
|
||||
let vcs = k.virtual_corners();
|
||||
assert_eq!(vcs.len(), 1, "{vcs:?}");
|
||||
assert_eq!(vcs[0].1.len(), 2, "{vcs:?}");
|
||||
assert!(vcs[0].1.iter().all(|(a, b)| a.dist(vc) < 1e-9 && (b.dist(vc) - 20.0).abs() < 1e-6), "{vcs:?}");
|
||||
// The dimensions still drive the part, measured to the virtual corner.
|
||||
run(&mut s, "parameters.change", json!({"name": len, "expression": "90 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": height, "expression": "120 mm"}));
|
||||
assert!(solved(&s));
|
||||
let (lo, hi) = sk(&s).bounds().unwrap();
|
||||
assert!((hi.x - lo.x - 90.0).abs() < 1e-6 && (hi.y - lo.y - 120.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
// It is kept in the design file, and undo and redo take it away and bring it back.
|
||||
let id = s.active_sketch.unwrap();
|
||||
let doc = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(doc.sketch(id).unwrap(), s.doc.sketch(id).unwrap());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sk(&s).point_index(&corner).is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(sk(&s).point_index(&corner).is_some());
|
||||
// A fillet without anything measuring to its corner leaves no virtual corner behind.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 100], [-80, 100]], "ids": ["riser", "top"]}));
|
||||
let n = sk(&s).points.len();
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
|
||||
assert_eq!(f["virtual_corners"], json!([]), "{f}");
|
||||
// Corner gone, two tangent points and the arc's centre added.
|
||||
assert_eq!(sk(&s).points.len(), n + 2);
|
||||
}
|
||||
|
||||
/// Lines meeting almost straight or almost folded back: a fillet or chamfer with dimensions on
|
||||
/// the corner either works or refuses with an error, never a panic.
|
||||
#[test]
|
||||
fn a_fillet_of_a_nearly_straight_or_folded_corner_with_dimensions_does_not_panic() {
|
||||
for (x, y) in [(100.0, 0.05), (100.0, -0.05), (100.0, 1e-9), (-100.0, 0.05), (-100.0, 1e-9)] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100.0 + x, y]], "ids": ["a", "b"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["a"], "value": 100}));
|
||||
for r in [0.01, 1.0, 50.0] {
|
||||
let _ = s.execute("sketch.fillet", &json!({"point": "a.end", "radius": r}));
|
||||
let _ = s.execute("sketch.chamfer.equal_distance", &json!({"point": "a.end", "distance": r}));
|
||||
}
|
||||
assert!(sk(&s).points.iter().all(|p| p.pos.is_finite()));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_fillet")
|
||||
.enabled(in_sketch)
|
||||
.params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"),
|
||||
.params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)"),
|
||||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
@@ -505,8 +505,71 @@ fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a rounded or cut corner's point: kept as a virtual sharp corner (its id),
|
||||
/// and the constraints and dimensions that now measure to it.
|
||||
#[derive(Default)]
|
||||
struct CornerKept {
|
||||
virtual_corner: Option<String>,
|
||||
moved: Vec<String>,
|
||||
}
|
||||
|
||||
/// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`)
|
||||
/// when anything measures to it: a constraint or dimension on the point, the length of either
|
||||
/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner,
|
||||
/// held on both curves' extensions (their intersection), and the line lengths become distances
|
||||
/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the
|
||||
/// caller removes the point.
|
||||
fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result<Option<CornerKept>> {
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
let mut moved: Vec<String> = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect();
|
||||
for k in sk.constraints.iter_mut() {
|
||||
let ConstraintKind::Length { l, value } = k.kind else { continue };
|
||||
let new_end = if l == ca {
|
||||
na
|
||||
} else if l == cb {
|
||||
nb
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|x| &x.kind) else { continue };
|
||||
// Same direction as the line, so the dimension keeps its side and text place.
|
||||
k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } };
|
||||
moved.push(k.id.clone());
|
||||
}
|
||||
if moved.is_empty() && !used && c != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
for cu in [ca, cb] {
|
||||
let k = ConstraintKind::PointOnCurve { p: c, c: cu };
|
||||
if !sk.constraints.iter().any(|x| x.kind == k) {
|
||||
add_c(sk, k)?;
|
||||
}
|
||||
}
|
||||
// A point of its own now: `l1.end` names the line's new end, not the virtual corner.
|
||||
let virtual_corner = if used || c == 0 {
|
||||
None
|
||||
} else {
|
||||
let id = sk.fresh("corner");
|
||||
if let Some(pt) = sk.points.get_mut(c) {
|
||||
pt.id = id.clone();
|
||||
}
|
||||
Some(id)
|
||||
};
|
||||
Ok(Some(CornerKept { virtual_corner, moved }))
|
||||
}
|
||||
|
||||
/// Points at distances `da` along a and `db` along b from the corner; lines shortened to them.
|
||||
fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: usize, da: f64, db: f64, cmd: &str) -> Result<(usize, usize, Vec2)> {
|
||||
fn cut_corner(
|
||||
sk: &mut Sketch,
|
||||
la: usize,
|
||||
lb: usize,
|
||||
c: usize,
|
||||
fa: usize,
|
||||
fb: usize,
|
||||
da: f64,
|
||||
db: f64,
|
||||
cmd: &str,
|
||||
) -> Result<(usize, usize, Vec2, CornerKept)> {
|
||||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||||
if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) {
|
||||
return Err(bad(cmd, "too large for these lines"));
|
||||
@@ -517,13 +580,14 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
|
||||
let tb = sk.add_point(pc + v * db, None)?;
|
||||
reattach(sk, la, c, ta);
|
||||
reattach(sk, lb, c, tb);
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
if !used && c != 0 {
|
||||
let kept = keep_corner(sk, c, [(la, ta), (lb, tb)])?;
|
||||
let removed = kept.is_none();
|
||||
if removed {
|
||||
sk.remove_points(&[c]);
|
||||
}
|
||||
// Indices may have shifted when the corner point was removed.
|
||||
let fix = |i: usize| if !used && c != 0 && i > c { i - 1 } else { i };
|
||||
Ok((fix(ta), fix(tb), pc))
|
||||
// Indices shift when the corner point was removed.
|
||||
let fix = |i: usize| if removed && i > c { i - 1 } else { i };
|
||||
Ok((fix(ta), fix(tb), pc, kept.unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -541,9 +605,10 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "radius must be positive"));
|
||||
}
|
||||
let pname = doc.new_model_param(&r_expr, "mm");
|
||||
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
|
||||
let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for q in &corners {
|
||||
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
|
||||
let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?;
|
||||
kept.push(k);
|
||||
// One radius for them all: the first carries the dimension, the rest are equal to it.
|
||||
match arcs.first() {
|
||||
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
|
||||
@@ -552,14 +617,27 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cons.extend(c);
|
||||
arcs.push(arc);
|
||||
}
|
||||
Ok((ids_of(sk, &arcs), cons, pname))
|
||||
Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept)))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
|
||||
let (vc, moved) = out.3;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
}
|
||||
|
||||
/// The virtual corners kept and the constraints moved to them, for a response.
|
||||
fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
|
||||
let vcs = kept.iter().filter_map(|k| k.virtual_corner.clone()).collect();
|
||||
let mut moved: Vec<String> = Vec::new();
|
||||
for id in kept.into_iter().flat_map(|k| k.moved) {
|
||||
if !moved.contains(&id) {
|
||||
moved.push(id);
|
||||
}
|
||||
}
|
||||
(vcs, moved)
|
||||
}
|
||||
|
||||
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
|
||||
/// the arc and the tangent constraints.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
|
||||
/// the arc, the tangent constraints and what became of the corner point.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
|
||||
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
|
||||
Ok(x) => x,
|
||||
// Not two lines: a corner with an arc.
|
||||
@@ -578,7 +656,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
}
|
||||
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
|
||||
let center = pc + bis * (r / half.sin());
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
|
||||
let ccw = (qa - center).cross(qb - center) > 0.0;
|
||||
let arc = if ccw {
|
||||
@@ -587,13 +665,13 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
|
||||
};
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
|
||||
Ok((arc, cons))
|
||||
Ok((arc, cons, kept))
|
||||
}
|
||||
|
||||
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
|
||||
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
|
||||
/// that kind.
|
||||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>)>> {
|
||||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>, CornerKept)>> {
|
||||
let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)),
|
||||
_ => None,
|
||||
@@ -655,14 +733,15 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
|
||||
let arc =
|
||||
if ccw { sk.add_arc(cen, ta, tb, [None, Some(na), Some(nb)], None)? } else { sk.add_arc(cen, tb, ta, [None, Some(nb), Some(na)], None)? };
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: arc })?];
|
||||
let used = sk.curves.iter().any(|x| x.kind.uses(q));
|
||||
if !used && q != 0 {
|
||||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||||
sk.remove_points(&[q]);
|
||||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||||
return Ok(Some((arc, cons)));
|
||||
match keep_corner(sk, q, [(ca, na), (cb, nb)])? {
|
||||
Some(kept) => Ok(Some((arc, cons, kept))),
|
||||
None => {
|
||||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||||
sk.remove_points(&[q]);
|
||||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||||
Ok(Some((arc, cons, CornerKept::default())))
|
||||
}
|
||||
}
|
||||
Ok(Some((arc, cons)))
|
||||
}
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
|
||||
@@ -672,11 +751,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
|
||||
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
|
||||
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
|
||||
let (da, db) = dists(corner_angle)?;
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||||
let l = sk.add_line_pts(ta, tb, None)?;
|
||||
Ok(ids_of(sk, &[l]))
|
||||
Ok((ids_of(sk, &[l]), kept_json(vec![kept])))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
let (curves, (vc, moved)) = out;
|
||||
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
}
|
||||
|
||||
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -82,7 +82,7 @@ fn classify(e: &EngineError) -> Outcome {
|
||||
match e {
|
||||
EngineError::Internal(..) => Outcome::new("panic", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
|
||||
// A failed boolean is a failure even when it says "not supported" (coincident faces on
|
||||
// A failed boolean is a failure even when it says "not supported" (the planar fallback on
|
||||
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
|
||||
// fillet between curved faces) are limitations.
|
||||
// Picks the generator got wrong (a centroid off its face).
|
||||
|
||||
@@ -419,6 +419,46 @@ fn sample_edges_chain_and_faces() {
|
||||
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
|
||||
}
|
||||
|
||||
/// timeline.inspect returns a feature's definition in the shape timeline.edit's `set` takes, so
|
||||
/// an agent can read the field names instead of guessing them.
|
||||
#[test]
|
||||
fn timeline_inspect_shows_what_timeline_edit_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Slab"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "name": "Round"}));
|
||||
let i = run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
|
||||
assert_eq!(i["type"], "ExtrudeFeature", "{i}");
|
||||
assert_eq!(i["definition"]["extent"]["distance"], "10", "{i}");
|
||||
assert_eq!(i["definition"]["operation"], "new_body", "{i}");
|
||||
assert!(i["definition"].get("type").is_none() && i["definition"].get("param_names").is_none(), "{i}");
|
||||
assert_eq!(i["inputs"][0]["label"], "Distance", "{i}");
|
||||
assert_eq!(i["inputs"][0]["unit"], "mm", "{i}");
|
||||
let d = i["inputs"][0]["param"].as_str().unwrap().to_string();
|
||||
// The definition's field names are what timeline.edit takes.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Slab", "set": {"extent": {"distance": "12"}}}));
|
||||
let i = run(&mut s, "timeline.inspect", json!({"feature": i["id"]}));
|
||||
assert_eq!(i["definition"]["extent"]["distance"], "12", "{i}");
|
||||
assert_eq!(i["inputs"][0]["param"], d.as_str(), "{i}");
|
||||
let f = run(&mut s, "timeline.inspect", json!({"feature": "Round"}));
|
||||
assert_eq!(f["definition"]["radius"], "2", "{f}");
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Round", "set": {"radius": "3"}}));
|
||||
assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Round"}))["definition"]["radius"], "3");
|
||||
// A sketch leaves its geometry out (sketch.inspect has it) and says so.
|
||||
let k = run(&mut s, "timeline.inspect", json!({"feature": "Base"}));
|
||||
assert!(k["definition"].get("sketch").is_none() && k["definition"].get("plane").is_some(), "{k}");
|
||||
assert_eq!(k["omitted"][0]["field"], "sketch", "{k}");
|
||||
// Read-only: no undo step; bad input is refused.
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
for bad in [json!({}), json!({"feature": "Nope"}), json!({"feature": [1]}), json!({"feature": 1e300}), json!({"feature": null})] {
|
||||
assert!(s.execute("timeline.inspect", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_edits_reresolve_references() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -95,6 +95,13 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool
|
||||
out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline));
|
||||
}
|
||||
}
|
||||
// Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the
|
||||
// curves to the corner, like construction geometry.
|
||||
for (_, ext) in sk.virtual_corners() {
|
||||
for (a, b) in ext {
|
||||
out.push((vec![plane.to_world(a), plane.to_world(b)], colors::SKETCH_CONSTRUCTION, true));
|
||||
}
|
||||
}
|
||||
// 3D curves are in world coordinates already.
|
||||
for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) {
|
||||
// Curves drawn in a 3D sketch look like the sketch's own; included ones are references.
|
||||
|
||||
@@ -785,7 +785,8 @@ fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
|
||||
Loop2 { segs }
|
||||
}
|
||||
|
||||
/// Ruled loft through two or more planar sections (outer loops only).
|
||||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||||
/// same sections in reverse order give the same body.
|
||||
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
if sections.len() < 2 || sections.len() > 50 {
|
||||
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
|
||||
@@ -794,10 +795,21 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid("empty section".into()));
|
||||
}
|
||||
// Orient every section counter-clockwise about the first section's normal, then match
|
||||
// segment counts and starting points to the section with the most segments.
|
||||
let n0 = sections[0].0.normal();
|
||||
let oriented: Vec<(Plane, Loop2)> = sections
|
||||
// A canonical order, so that reversing the sections changes nothing: the one whose
|
||||
// first-to-last step has a positive largest component. The side faces' parameterisation
|
||||
// follows the order, and the boolean worked on one and failed on the other (a cut whose
|
||||
// loft touches the body along an edge: an internal panic, or "shell not oriented and
|
||||
// closed").
|
||||
let centre = |(p, l): &(Plane, Loop2)| p.to_world(l.centroid());
|
||||
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
|
||||
let step = centre(last) - centre(first);
|
||||
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
|
||||
let ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() };
|
||||
// Orient every section counter-clockwise about the first section's normal (`Body::new`
|
||||
// turns the solid outward), then match segment counts and starting points to the section
|
||||
// with the most segments.
|
||||
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
|
||||
let oriented: Vec<(Plane, Loop2)> = ordered
|
||||
.iter()
|
||||
.map(|(p, l)| {
|
||||
let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() };
|
||||
|
||||
+222
-30
@@ -2,8 +2,10 @@
|
||||
//! faces). Each face moves along its outward normal by its own distance: a plane is translated,
|
||||
//! a cylinder changes radius about its axis. Every vertex and edge goes to where its faces'
|
||||
//! moved surfaces meet; edges are lines or circular arcs. This is exact when the moved
|
||||
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls);
|
||||
//! anything else is refused, so callers can fall back.
|
||||
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls).
|
||||
//! A corner where more than three planes meet, whose moved planes miss a common point (a
|
||||
//! floor falling to a drain, a pyramid's apex), becomes several corners joined by short edges.
|
||||
//! Anything else is refused, so callers can fall back.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -422,6 +424,143 @@ fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Most faces round one corner that [`split_corner`] takes on.
|
||||
const MAX_SPLIT_FACES: usize = 48;
|
||||
|
||||
/// The corners in place of a corner where more than three planar faces meet and whose moved
|
||||
/// planes miss a common point (a hip floor's corner, a pyramid's apex): each new corner with
|
||||
/// the faces through it. Corners sharing two faces are joined by a new edge on those faces.
|
||||
/// Only corners whose edges are all convex or all concave: the faces there bound a convex
|
||||
/// region (the body's side or the outside's), and after the move its corners are the points on
|
||||
/// three moved planes inside all the others.
|
||||
fn split_corner(
|
||||
v: &mt::Vertex,
|
||||
at: &[usize],
|
||||
faces: &[mt::Face],
|
||||
efaces: &HashMap<mt::EdgeID, Vec<usize>>,
|
||||
old: &[Surf],
|
||||
new: &[Surf],
|
||||
size: f64,
|
||||
) -> Option<Vec<(Vec3, Vec<usize>)>> {
|
||||
if at.len() < 4 || at.len() > MAX_SPLIT_FACES {
|
||||
return None;
|
||||
}
|
||||
let p = from_p3(v.point());
|
||||
// (face, outward normal, old offset, moved offset).
|
||||
let planes: Vec<(usize, Vec3, f64, f64)> = at
|
||||
.iter()
|
||||
.map(|i| match (old.get(*i)?, new.get(*i)?) {
|
||||
(Surf::Plane { n, d }, Surf::Plane { d: d2, .. }) => Some((*i, *n, *d, *d2)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let normal = |fi: usize| planes.iter().find(|x| x.0 == fi).map(|x| x.1);
|
||||
// Edge kinds round the corner: a face's loop runs counter-clockwise about its outward
|
||||
// normal, so its inside is to the left; the edge is convex when the face beyond it falls
|
||||
// away from there.
|
||||
let mut sign = 0.0f64;
|
||||
let mut pairs: Vec<(usize, usize)> = Vec::new();
|
||||
for &(fi, na, ..) in &planes {
|
||||
for w in faces.get(fi)?.boundaries() {
|
||||
for e in w.edge_iter() {
|
||||
if e.front().id() != v.id() && e.back().id() != v.id() {
|
||||
continue;
|
||||
}
|
||||
let fb = efaces.get(&e.id())?.iter().copied().find(|j| *j != fi)?;
|
||||
pairs.push((fi, fb));
|
||||
let dir = (from_p3(e.back().point()) - from_p3(e.front().point())).normalized()?;
|
||||
let s = normal(fb)?.dot(na.cross(dir));
|
||||
if s.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if sign * s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
sign = s.signum();
|
||||
}
|
||||
}
|
||||
}
|
||||
if sign == 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Convex: the body's side, n·x ≤ d; concave: the outside's, n·x ≥ d.
|
||||
let side = -sign;
|
||||
let merge = size * 1e-6;
|
||||
let mut nodes: Vec<(Vec3, Vec<usize>)> = Vec::new();
|
||||
for (ia, a) in planes.iter().enumerate() {
|
||||
for (ib, b) in planes.iter().enumerate().skip(ia + 1) {
|
||||
for c in planes.iter().skip(ib + 1) {
|
||||
let den = a.1.dot(b.1.cross(c.1));
|
||||
if den.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let x = (b.1.cross(c.1) * a.3 + c.1.cross(a.1) * b.3 + a.1.cross(b.1) * c.3) * (1.0 / den);
|
||||
if !x.is_finite() || x.dist(p) > size || planes.iter().any(|q| side * (q.1.dot(x) - q.3) > merge) {
|
||||
continue;
|
||||
}
|
||||
let fs = [a.0, b.0, c.0];
|
||||
match nodes.iter_mut().find(|(q, _)| q.dist(x) < merge) {
|
||||
Some((_, nf)) => {
|
||||
for f in fs {
|
||||
if !nf.contains(&f) {
|
||||
nf.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => nodes.push((x, fs.to_vec())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every face keeps a corner here, and every edge an end.
|
||||
if planes.iter().any(|q| !nodes.iter().any(|(_, nf)| nf.contains(&q.0)))
|
||||
|| pairs.iter().any(|(x, y)| !nodes.iter().any(|(_, nf)| nf.contains(x) && nf.contains(y)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Each corner where its planes meet best (least squares, for planes meeting at one point
|
||||
// to within the merge distance).
|
||||
for (q, nf) in nodes.iter_mut() {
|
||||
let (ns, ss): (Vec<Vec3>, Vec<f64>) = nf.iter().filter_map(|f| planes.iter().find(|x| x.0 == *f)).map(|x| (x.1, x.3 - x.2)).unzip();
|
||||
*q = p + span_step(&ns, &ss)?;
|
||||
}
|
||||
Some(nodes)
|
||||
}
|
||||
|
||||
/// The corners of a split corner (see [`split_corner`]) on face `face`, in order from corner
|
||||
/// `from` to corner `to`, along the new edges between them.
|
||||
fn split_path(nodes: &[(Vec3, Vec<usize>)], face: usize, from: usize, to: usize) -> Option<Vec<usize>> {
|
||||
let joined = |x: usize, y: usize| {
|
||||
let (Some((_, a)), Some((_, b))) = (nodes.get(x), nodes.get(y)) else { return false };
|
||||
a.contains(&face) && b.contains(&face) && a.iter().filter(|f| b.contains(f)).count() >= 2
|
||||
};
|
||||
// Breadth first; `prev` leads back to `from`.
|
||||
let mut prev: Vec<Option<usize>> = vec![None; nodes.len()];
|
||||
let mut queue = std::collections::VecDeque::from([from]);
|
||||
let mut seen = vec![false; nodes.len()];
|
||||
*seen.get_mut(from)? = true;
|
||||
while let Some(x) = queue.pop_front() {
|
||||
if x == to {
|
||||
let mut path = vec![to];
|
||||
let mut cur = to;
|
||||
while let Some(p) = prev.get(cur).copied().flatten() {
|
||||
path.push(p);
|
||||
cur = p;
|
||||
}
|
||||
path.reverse();
|
||||
return Some(path);
|
||||
}
|
||||
for y in 0..nodes.len() {
|
||||
if !seen.get(y).copied().unwrap_or(true) && joined(x, y) {
|
||||
*seen.get_mut(y)? = true;
|
||||
*prev.get_mut(y)? = Some(x);
|
||||
queue.push_back(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A line or circular arc through an edge (by samples): `None` for other curves.
|
||||
/// Maximum straight edges per face checked pairwise for crossings.
|
||||
const MAX_CROSSING_EDGES: usize = 2000;
|
||||
@@ -431,7 +570,7 @@ const MAX_CROSSING_EDGES: usize = 2000;
|
||||
fn crossed_loops(
|
||||
faces: &[mt::Face],
|
||||
old: &[Surf],
|
||||
newpos: &HashMap<mt::VertexID, Vec3>,
|
||||
pos: &dyn Fn(&mt::Vertex, &mt::Edge) -> Option<Vec3>,
|
||||
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
|
||||
size: f64,
|
||||
) -> Option<Vec3> {
|
||||
@@ -443,32 +582,32 @@ fn crossed_loops(
|
||||
}
|
||||
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
|
||||
let flat = |p: Vec3| (p.dot(u), p.dot(w));
|
||||
// Straight edges as (vertex ids, old ends, new ends) in the plane.
|
||||
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
|
||||
// Straight edges as (vertex ids, new ends in the plane, the plane's new offset).
|
||||
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2], f64)> = Vec::new();
|
||||
for wire in f.absolute_boundaries() {
|
||||
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
|
||||
for e in wire.edge_iter() {
|
||||
let (v0, v1) = (e.front(), e.back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
|
||||
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
|
||||
let (Some(q0), Some(q1)) = (pos(v0, e), pos(v1, e)) else { return None };
|
||||
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(q0), flat(q1));
|
||||
area_old += o0.0 * o1.1 - o1.0 * o0.1;
|
||||
area_new += n0.0 * n1.1 - n1.0 * n0.1;
|
||||
if arc_mid(e, size * 1e-6) == Some(None) {
|
||||
segs.push(([v0.id(), v1.id()], [n0, n1]));
|
||||
segs.push(([v0.id(), v1.id()], [n0, n1], n.dot(q0)));
|
||||
} else {
|
||||
straight = false;
|
||||
}
|
||||
}
|
||||
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
|
||||
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
|
||||
return wire.edge_iter().next().and_then(|e| pos(e.front(), e));
|
||||
}
|
||||
}
|
||||
if segs.len() > MAX_CROSSING_EDGES {
|
||||
continue;
|
||||
}
|
||||
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
|
||||
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
|
||||
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
|
||||
for (i, (ia, [a0, a1], d)) in segs.iter().enumerate() {
|
||||
for (ib, [b0, b1], _) in segs.iter().skip(i + 1) {
|
||||
if ia.iter().any(|v| ib.contains(v)) {
|
||||
continue;
|
||||
}
|
||||
@@ -478,8 +617,7 @@ fn crossed_loops(
|
||||
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
|
||||
let k = t0 / (t0 - t1);
|
||||
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
|
||||
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
|
||||
return Some(u * x + w * y + n * d);
|
||||
return Some(u * x + w * y + n * *d);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,6 +756,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
})
|
||||
.collect();
|
||||
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
|
||||
// Corners whose moved faces meet at several points (see `split_corner`).
|
||||
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
|
||||
for v in b.solid.vertex_iter() {
|
||||
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let p = from_p3(v.point());
|
||||
@@ -672,10 +812,33 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
});
|
||||
let q = match apex {
|
||||
Some(q) if n.iter().all(|sf| sf.dist(q).abs() < size * 1e-6) => q,
|
||||
_ => move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?,
|
||||
_ => match move_point(p, &o, &s, &n, size * 1e-6) {
|
||||
Some(q) => q,
|
||||
None => {
|
||||
let nodes = (fs == *at)
|
||||
.then(|| split_corner(&v, at, &faces, &efaces, &old, &new, size))
|
||||
.flatten()
|
||||
.ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?;
|
||||
let q = nodes.first().map(|x| x.0).ok_or_else(|| fail("corner"))?;
|
||||
splits.insert(v.id(), nodes);
|
||||
q
|
||||
}
|
||||
},
|
||||
};
|
||||
newpos.insert(v.id(), q);
|
||||
}
|
||||
// Which of a split corner's new corners an edge ends at.
|
||||
let node_of = |v: mt::VertexID, e: mt::EdgeID| -> Option<usize> {
|
||||
let fs = efaces.get(&e)?;
|
||||
splits.get(&v)?.iter().position(|(_, nf)| fs.iter().all(|i| nf.contains(i)))
|
||||
};
|
||||
// Where an edge's end goes.
|
||||
let end_pos = |v: &mt::Vertex, e: &mt::Edge| -> Option<Vec3> {
|
||||
match splits.get(&v.id()) {
|
||||
Some(nodes) => nodes.get(node_of(v.id(), e.id())?).map(|x| x.0),
|
||||
None => newpos.get(&v.id()).copied(),
|
||||
}
|
||||
};
|
||||
// An edge turned round: faces crossed over (a wall thinner than the move), which would
|
||||
// leave the body inside out.
|
||||
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
|
||||
@@ -684,8 +847,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
continue;
|
||||
}
|
||||
let (f0, f1) = (e.absolute_front(), e.absolute_back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
|
||||
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
|
||||
let (Some(q0), Some(q1)) = (end_pos(f0, &e), end_pos(f1, &e)) else { continue };
|
||||
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), q1 - q0);
|
||||
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
|
||||
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
|
||||
}
|
||||
@@ -693,7 +856,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
|
||||
// in a top face sliding past its outer edge) turns part of the face inside out, though no
|
||||
// edge turns round.
|
||||
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
|
||||
if let Some(at) = crossed_loops(&faces, &old, &end_pos, &edge_collapsed, size) {
|
||||
return Err(fail(&format!(
|
||||
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
|
||||
at.x, at.y, at.z
|
||||
@@ -725,6 +888,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
};
|
||||
verts.insert(v.id(), nv);
|
||||
}
|
||||
let node_verts: HashMap<mt::VertexID, Vec<mt::Vertex>> =
|
||||
splits.iter().map(|(id, nodes)| (*id, nodes.iter().map(|(q, _)| builder::vertex(p3(*q))).collect())).collect();
|
||||
let vert_at = |v: &mt::Vertex, e: &mt::Edge| -> Option<&mt::Vertex> {
|
||||
match node_verts.get(&v.id()) {
|
||||
Some(nv) => nv.get(node_of(v.id(), e.id())?),
|
||||
None => verts.get(&v.id()),
|
||||
}
|
||||
};
|
||||
// New edges between a split corner's corners (stored from the lower index).
|
||||
let mut links: HashMap<(mt::VertexID, usize, usize), mt::Edge> = HashMap::new();
|
||||
// Old edge → new edge, and whether it runs the other way (merged with its twin).
|
||||
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
|
||||
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
|
||||
@@ -732,7 +905,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
|
||||
let (Some(a), Some(c)) = (vert_at(e.absolute_front(), &e), vert_at(e.absolute_back(), &e)) else { return Err(fail("edge")) };
|
||||
let touches = edge_collapsed(&e);
|
||||
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
|
||||
if touches {
|
||||
@@ -796,18 +969,37 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
if is_collapsed(i) {
|
||||
continue;
|
||||
}
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| {
|
||||
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let old_edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
|
||||
let mut wire: Vec<mt::Edge> = Vec::new();
|
||||
for (j, e) in old_edges.iter().enumerate() {
|
||||
if let Some((ne, flip)) = edges.get(&e.id()) {
|
||||
wire.push(if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() });
|
||||
}
|
||||
// At a split corner, along the new edges from this edge's end to the next's.
|
||||
let v = e.back();
|
||||
let (Some(nodes), Some(next)) = (splits.get(&v.id()), old_edges.get((j + 1) % old_edges.len())) else { continue };
|
||||
let (Some(from), Some(to)) = (node_of(v.id(), e.id()), node_of(v.id(), next.id())) else { return Err(fail("split corner")) };
|
||||
let path = split_path(nodes, i, from, to).ok_or_else(|| fail("split corner"))?;
|
||||
for pair in path.windows(2) {
|
||||
let (Some(&x), Some(&y)) = (pair.first(), pair.get(1)) else { continue };
|
||||
let key = (v.id(), x.min(y), x.max(y));
|
||||
let link = match links.get(&key) {
|
||||
Some(l) => l.clone(),
|
||||
None => {
|
||||
let nv = node_verts.get(&v.id()).ok_or_else(|| fail("split corner"))?;
|
||||
let (Some(va), Some(vb)) = (nv.get(key.1), nv.get(key.2)) else { return Err(fail("split corner")) };
|
||||
let l = builder::line(va, vb);
|
||||
links.insert(key, l.clone());
|
||||
l
|
||||
}
|
||||
};
|
||||
wire.push(if x < y { link } else { link.inverse() });
|
||||
}
|
||||
}
|
||||
wires.push(wire.into());
|
||||
}
|
||||
let (Some(o), Some(n)) = (old.get(i), new.get(i)) else { return Err(fail("face")) };
|
||||
let surface = match (o.map_to(n), *o, *n) {
|
||||
(Some(mat), _, _) => mt::Transformed::transformed(&f.surface(), mat),
|
||||
|
||||
@@ -266,9 +266,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let size = a.size().max(b.size());
|
||||
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
|
||||
// thin part trimmed by a long one), a step from their size would facet the small faces
|
||||
// there coarsely enough to fail the membership test on a correct result.
|
||||
// there coarsely enough to fail the membership test on a correct result. The meshes are
|
||||
// refined: a round side trimmed to a thin band (a short boss crossing a gently sloping
|
||||
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
|
||||
// material.
|
||||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
||||
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
|
||||
let (va, vb) = match &meshes {
|
||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||
_ => (volume(a), volume(b)),
|
||||
@@ -277,7 +280,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// Volume bounds, then membership against the operands (catches misclassified pieces), on
|
||||
// one mesh of the result. Returns (plausible, volume).
|
||||
let check = |r: &Body| -> (bool, f64) {
|
||||
let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) };
|
||||
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
|
||||
let v = mr.measure().volume;
|
||||
let bounds = v > 0.0
|
||||
&& match op {
|
||||
|
||||
@@ -199,16 +199,72 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polygons of a body whose faces are all planar (None otherwise).
|
||||
fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
|
||||
/// Why a body's polygons can't be had: in words that finish "the {role} has …" or "the
|
||||
/// {role}'s …".
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum NotPlanar {
|
||||
/// Faces on curved surfaces, counted by kind.
|
||||
Curved(std::collections::BTreeMap<&'static str, usize>),
|
||||
/// Something else (a face that could not be meshed, too many polygons).
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl NotPlanar {
|
||||
fn describe(&self, role: &str) -> String {
|
||||
match self {
|
||||
NotPlanar::Curved(kinds) => {
|
||||
let n: usize = kinds.values().sum();
|
||||
let list: Vec<&str> = kinds.keys().copied().collect();
|
||||
format!("the {role} has {n} curved face{} ({})", if n == 1 { "" } else { "s" }, list.join(", "))
|
||||
}
|
||||
NotPlanar::Other(m) => format!("the {role}'s {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane of each face (from its mesh, which must be flat), or what stands in the way: the
|
||||
/// curved faces by the kind of surface they lie on.
|
||||
fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, NotPlanar> {
|
||||
use crate::FaceSurface;
|
||||
let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new();
|
||||
let mut out = Vec::with_capacity(faces.len());
|
||||
for f in faces {
|
||||
if f.area <= 0.0 {
|
||||
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
|
||||
}
|
||||
match f.plane_normal {
|
||||
Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }),
|
||||
None => {
|
||||
let kind = match crate::face_surface(b, f.index) {
|
||||
Some(FaceSurface::Cylinder { .. }) => "cylinder",
|
||||
Some(FaceSurface::Cone { .. }) => "cone",
|
||||
Some(FaceSurface::Sphere { .. }) => "sphere",
|
||||
Some(FaceSurface::Torus { .. }) => "torus",
|
||||
Some(FaceSurface::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
|
||||
None => "freeform",
|
||||
};
|
||||
*curved.entry(kind).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) }
|
||||
}
|
||||
|
||||
/// Polygons of a body whose faces are all planar.
|
||||
fn planar_polys(b: &Body) -> std::result::Result<Vec<Poly>, NotPlanar> {
|
||||
let tol = (b.size() * 1e-3).max(1e-3);
|
||||
let faces = b.faces(tol).ok()?;
|
||||
let mesh = b.tessellate(tol).ok()?;
|
||||
let planes: Vec<PlaneEq> = faces.iter().map(|f| f.plane_normal.map(|n| PlaneEq { n, w: n.dot(f.centroid) })).collect::<Option<_>>()?;
|
||||
let unmeshed = |e: KernelError| NotPlanar::Other(format!("faces could not be meshed ({e})"));
|
||||
let faces = b.faces(tol).map_err(unmeshed)?;
|
||||
let mesh = b.tessellate(tol).map_err(unmeshed)?;
|
||||
let planes = face_planes(b, &faces)?;
|
||||
if mesh.triangles.len() > MAX_POLYS {
|
||||
return Err(NotPlanar::Other(format!("mesh is too detailed ({} triangles)", mesh.triangles.len())));
|
||||
}
|
||||
let bad_mesh = || NotPlanar::Other("mesh is inconsistent".into());
|
||||
let mut out = Vec::new();
|
||||
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let [a, bb, c] = mesh.tri(t)?;
|
||||
let plane = *planes.get(*fi as usize)?;
|
||||
let [a, bb, c] = mesh.tri(t).ok_or_else(bad_mesh)?;
|
||||
let plane = *planes.get(*fi as usize).ok_or_else(bad_mesh)?;
|
||||
// Snap the vertices onto the exact face plane.
|
||||
let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w);
|
||||
let mut v = vec![snap(a), snap(bb), snap(c)];
|
||||
@@ -217,7 +273,7 @@ fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
|
||||
}
|
||||
out.push(Poly { v, plane });
|
||||
}
|
||||
(out.len() <= MAX_POLYS).then_some(out)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Polygons of any body: planar faces exactly, curved ones as their triangles (within `tol`).
|
||||
@@ -305,11 +361,19 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
|
||||
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
||||
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||
return r;
|
||||
let (pa, pb) = match (planar_polys(a), planar_polys(b)) {
|
||||
(Ok(pa), Ok(pb)) => (pa, pb),
|
||||
(ra, rb) => {
|
||||
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||
return r;
|
||||
}
|
||||
let why: Vec<String> =
|
||||
[(ra.err(), "target"), (rb.err(), "tool")].into_iter().filter_map(|(e, role)| e.map(|e| e.describe(role))).collect();
|
||||
return Err(KernelError::Failed(format!(
|
||||
"not supported yet: the exact fallback for where the bodies meet (faces that touch, or cross at a shallow angle) takes planar faces only, and {}",
|
||||
why.join(" and ")
|
||||
)));
|
||||
}
|
||||
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
||||
};
|
||||
let size = a.size().max(b.size());
|
||||
polys_boolean(pa, pb, size, op, size * 1e-9)
|
||||
@@ -320,7 +384,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
|
||||
/// pieces outside are joined back on. `None` when no such slab exists.
|
||||
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let pb = planar_polys(b)?;
|
||||
let pb = planar_polys(b).ok()?;
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (a.size() * 1e-3).max(1e-3);
|
||||
let infos = a.faces(tol).ok()?;
|
||||
@@ -391,7 +455,7 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
|
||||
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
|
||||
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
|
||||
}
|
||||
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
|
||||
let pm = planar_polys(&middle).map_err(|e| fail(&e.describe("middle piece")))?;
|
||||
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
|
||||
if op == BoolOp::Intersect {
|
||||
return Ok(r);
|
||||
|
||||
@@ -334,6 +334,73 @@ fn drain_pads_flush_under_a_floor_with_a_hole() {
|
||||
let v = measure(&u).unwrap().volume;
|
||||
let expect = v_floor + PI * 400.0 * 5.0;
|
||||
assert!(rel(v, expect) < 2e-5, "disc across the hole: {v} vs {expect}");
|
||||
// A boss ring standing on the sloping top around the hole, its foot just under the top
|
||||
// everywhere: it adds its annulus times its height above the top at the centre.
|
||||
let foot = 10.0 + t * 30.0 - 0.2;
|
||||
let u = boolean(&floor, &ring(foot, 14.0), BoolOp::Union).unwrap().unwrap();
|
||||
let v = measure(&u).unwrap().volume;
|
||||
let expect = v_floor + PI * 300.0 * (14.0 - (10.0 + t * 50.0));
|
||||
assert!(rel(v, expect) < 2e-5, "boss ring on the slope: {v} vs {expect}");
|
||||
assert!(u.validity().is_empty(), "{:?}", u.validity());
|
||||
}
|
||||
|
||||
/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads
|
||||
/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed
|
||||
/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain
|
||||
/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing
|
||||
/// material, rejected the result, and the planar fallback refused the round pad.
|
||||
#[test]
|
||||
fn round_pads_crossing_a_shallow_fall() {
|
||||
let hs = |n: Vec3, p: Vec3| {
|
||||
let n = n.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(p) }
|
||||
};
|
||||
let walls = |floor: &[HalfSpace]| {
|
||||
let mut h = vec![
|
||||
hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)),
|
||||
hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)),
|
||||
hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)),
|
||||
hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)),
|
||||
hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)),
|
||||
];
|
||||
h.extend_from_slice(floor);
|
||||
convex_polyhedron(&h).unwrap()
|
||||
};
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap();
|
||||
// One fall along x at 1°: floor z = 8 + t (x − 10).
|
||||
let t = 1f64.to_radians().tan();
|
||||
let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap();
|
||||
let v0 = measure(&fall).unwrap().volume;
|
||||
for x in [60.0, 100.0] {
|
||||
// A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above).
|
||||
let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap();
|
||||
let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap();
|
||||
// The part of the pad above the floor: the floor under the disc is planar, so its mean
|
||||
// height is the height at the centre (where the floor is below z = 9 everywhere).
|
||||
let floor = |x: f64| 8.0 + t * (x - 10.0);
|
||||
let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN };
|
||||
let v = measure(&u).unwrap().volume;
|
||||
if above.is_finite() {
|
||||
assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above);
|
||||
} else {
|
||||
assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}");
|
||||
}
|
||||
}
|
||||
// Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall.
|
||||
let t2 = 2f64.to_radians().tan();
|
||||
let d = Vec3::new(240.0, 110.0, 8.0);
|
||||
let hopper = walls(&[
|
||||
hs(Vec3::new(t2, 0.0, -1.0), d),
|
||||
hs(Vec3::new(-t2, 0.0, -1.0), d),
|
||||
hs(Vec3::new(0.0, t2, -1.0), d),
|
||||
hs(Vec3::new(0.0, -t2, -1.0), d),
|
||||
]);
|
||||
let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap();
|
||||
let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap();
|
||||
let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap();
|
||||
// Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor.
|
||||
let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume;
|
||||
assert!((gain - 6517.0).abs() < 10.0, "{gain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -363,6 +430,38 @@ fn planar_booleans_with_coincident_faces() {
|
||||
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// The planar fallback runs after any failed B-rep boolean, not only on coincident faces; when
|
||||
/// it can't run it says which body has which curved faces (it used to blame "coincident faces
|
||||
/// on curved bodies" whatever the cause).
|
||||
#[test]
|
||||
fn planar_fallback_names_the_curved_faces() {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
|
||||
let pad = cylinder(Vec3::new(20.0, 15.0, 5.0), Vec3::Z, 8.0, 10.0).unwrap();
|
||||
let e = planar_boolean(&slab, &pad, BoolOp::Union).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet"), "{e}");
|
||||
assert!(e.contains("the tool has 2 curved faces (cylinder)"), "{e}");
|
||||
assert!(!e.contains("target"), "{e}");
|
||||
assert!(!e.contains("coincident faces on curved bodies"), "{e}");
|
||||
let ball = sphere(Vec3::new(20.0, 15.0, 10.0), 5.0).unwrap();
|
||||
let e = planar_boolean(&ball, &pad, BoolOp::Cut).unwrap_err().to_string();
|
||||
assert!(e.contains("the target has 6 curved faces (sphere) and the tool has 2 curved faces (cylinder)"), "{e}");
|
||||
// All-planar bodies, a face of one at a shallow angle (1°): the fallback takes them.
|
||||
let slope = Vec3::new(-1f64.to_radians().tan(), 0.0, 1.0).normalized().unwrap();
|
||||
let wedge = convex_polyhedron(&[
|
||||
HalfSpace { n: Vec3::X, d: 30.0 },
|
||||
HalfSpace { n: -Vec3::X, d: -10.0 },
|
||||
HalfSpace { n: Vec3::Y, d: 20.0 },
|
||||
HalfSpace { n: -Vec3::Y, d: -10.0 },
|
||||
HalfSpace { n: -Vec3::Z, d: -5.0 },
|
||||
HalfSpace { n: slope, d: slope.dot(Vec3::new(10.0, 0.0, 11.0)) },
|
||||
])
|
||||
.unwrap();
|
||||
let u = planar_boolean(&slab, &wedge, BoolOp::Union).unwrap().unwrap();
|
||||
let above = box_solid(Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let expect = 40.0 * 30.0 * 10.0 + measure(&boolean(&wedge, &above, BoolOp::Intersect).unwrap().unwrap()).unwrap().volume;
|
||||
assert!(rel(measure(&u).unwrap().volume, expect) < 1e-6, "{} vs {expect}", measure(&u).unwrap().volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_logo_join_preserves_exact_faces_at_edge_contact() {
|
||||
let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap();
|
||||
@@ -1510,6 +1609,49 @@ fn smooth_loft_through_three_sections() {
|
||||
assert!(v > PI * 64.0 * 30.0 && v < 576.0 * 30.0, "{v}");
|
||||
}
|
||||
|
||||
/// A loft from the basin's whole outline (10 up) down to a drain pad in a corner (1 up), cut
|
||||
/// from a 3 mm shell: its top edges lie on the outer walls. One order of the sections failed
|
||||
/// the cut ("shell not oriented and closed", or a panic inside the intersection), the other
|
||||
/// worked; both orders now build the same body, valid and outward.
|
||||
#[test]
|
||||
fn loft_section_order_gives_the_same_body() {
|
||||
let r = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
|
||||
let big = (at(10.0), r(0.0, 0.0, 100.0, 60.0));
|
||||
let small = (at(1.0), r(80.0, 40.0, 90.0, 50.0));
|
||||
let basin = shell(&box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(), &[Vec3::new(50.0, 30.0, 30.0)], 3.0).unwrap();
|
||||
// The material inside the loft: the floor from 1 to 3 up, the walls from 3 to 10.
|
||||
let v0 = measure(&basin).unwrap().volume;
|
||||
let removed = v0 - 41582.028;
|
||||
let mut volumes = Vec::new();
|
||||
for secs in [vec![big.clone(), small.clone()], vec![small.clone(), big.clone()]] {
|
||||
let l = loft(&secs).unwrap();
|
||||
let m = measure(&l).unwrap();
|
||||
assert!(m.volume > 0.0, "{}", m.volume);
|
||||
volumes.push(m.volume);
|
||||
let cut = boolean(&basin, &l, BoolOp::Cut).unwrap().unwrap();
|
||||
let v = measure(&cut).unwrap().volume;
|
||||
assert!((v0 - v - removed).abs() < 0.01 * removed, "{v} vs {}", v0 - removed);
|
||||
let joined = boolean(&basin, &l, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(measure(&joined).unwrap().volume > v0);
|
||||
}
|
||||
assert!(rel(volumes[0], volumes[1]) < 1e-12, "{volumes:?}");
|
||||
// Reversed in z too (large below, small above, and sketched on planes facing down).
|
||||
let down = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::Y, Vec3::X).unwrap();
|
||||
for secs in [
|
||||
vec![(at(0.0), r(0.0, 0.0, 100.0, 60.0)), (at(20.0), r(40.0, 20.0, 60.0, 40.0))],
|
||||
vec![(down(20.0), r(20.0, 40.0, 40.0, 60.0)), (at(0.0), r(0.0, 0.0, 100.0, 60.0))],
|
||||
] {
|
||||
// Prismoid: h/6 · (A0 + 4 Amid + A1).
|
||||
let want = 20.0 / 6.0 * (6000.0 + 4.0 * 60.0 * 40.0 + 400.0);
|
||||
let back: Vec<(Plane, Loop2)> = secs.iter().rev().cloned().collect();
|
||||
for b in [loft(&secs).unwrap(), loft(&back).unwrap()] {
|
||||
let v = measure(&b).unwrap().volume;
|
||||
assert!(rel(v, want) < 1e-6, "{v} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_holes_pinch_alike() {
|
||||
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut
|
||||
@@ -1684,3 +1826,104 @@ fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trough whose floor falls about 1° toward a central drain, the fall starting right at the
|
||||
/// trough edges: four faces meet at each bottom corner (two walls, two fall facets) and at the
|
||||
/// drain (four facets). A tab on one end makes the body non-convex. The moved planes of four
|
||||
/// faces don't meet at one point, so each such corner of the cavity becomes two corners joined
|
||||
/// by a short edge. Checked against the volume integrated by hand.
|
||||
#[test]
|
||||
fn shell_where_four_faces_meet_at_a_corner() {
|
||||
let (w, l, ht, t) = (100.0, 60.0, 40.0, 2.0);
|
||||
// Drain depth: the long facets fall 1°.
|
||||
let h = 30.0 * 1f64.to_radians().tan();
|
||||
let facet = |n: Vec3, p: Vec3| {
|
||||
let n = n.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(p) }
|
||||
};
|
||||
let facets = [
|
||||
facet(Vec3::new(0.0, -h, -30.0), Vec3::ZERO),
|
||||
facet(Vec3::new(0.0, h, -30.0), Vec3::new(0.0, l, 0.0)),
|
||||
facet(Vec3::new(-h, 0.0, -50.0), Vec3::ZERO),
|
||||
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(w, 0.0, 0.0)),
|
||||
];
|
||||
let mut hs = vec![
|
||||
HalfSpace { n: -Vec3::X, d: 0.0 },
|
||||
HalfSpace { n: Vec3::X, d: w },
|
||||
HalfSpace { n: -Vec3::Y, d: 0.0 },
|
||||
HalfSpace { n: Vec3::Y, d: l },
|
||||
HalfSpace { n: Vec3::Z, d: ht },
|
||||
];
|
||||
hs.extend(facets);
|
||||
let trough = convex_polyhedron(&hs).unwrap();
|
||||
let pyramid = w * l * h / 3.0;
|
||||
assert!(rel(measure(&trough).unwrap().volume, w * l * ht + pyramid) < 1e-9);
|
||||
let tab = box_solid(Vec3::new(w, 20.0, 20.0), Vec3::new(w + 20.0, 40.0, ht)).unwrap();
|
||||
let body = boolean(&trough, &tab, BoolOp::Union).unwrap().unwrap();
|
||||
let s = shell(&body, &[Vec3::new(50.0, 30.0, ht)], t).unwrap();
|
||||
// The cavity: the walls in by t, above the floor facets moved in by t (midpoint rule; the
|
||||
// floor is linear between its creases).
|
||||
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d - t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
|
||||
let (nx, ny) = (960, 560);
|
||||
let (dx, dy) = ((w - 2.0 * t) / nx as f64, (l - 2.0 * t) / ny as f64);
|
||||
let mut cavity = 0.0;
|
||||
for i in 0..nx {
|
||||
for j in 0..ny {
|
||||
cavity += (ht - floor(t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy)) * dx * dy;
|
||||
}
|
||||
}
|
||||
let tab_cavity = 20.0 * (20.0 - 2.0 * t) * (20.0 - t);
|
||||
let want = w * l * ht + pyramid + 8000.0 - cavity - tab_cavity;
|
||||
let m = measure(&s).unwrap();
|
||||
assert!(rel(m.volume, want) < 1e-5, "{} vs {want}", m.volume);
|
||||
}
|
||||
|
||||
/// The same trough cut as a pocket into a slab (four faces meet at each concave corner of the
|
||||
/// pocket floor), shelled with its top open.
|
||||
#[test]
|
||||
fn shell_of_a_pocket_whose_floor_falls_to_a_drain() {
|
||||
let (px, py, pw, pl, top, depth, t) = (20.0, 15.0, 100.0, 60.0, 50.0, 30.0, 3.0);
|
||||
let h = 30.0 * 1f64.to_radians().tan();
|
||||
let z0 = top - depth;
|
||||
let facet = |n: Vec3, p: Vec3| {
|
||||
let n = n.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(p) }
|
||||
};
|
||||
let facets = [
|
||||
facet(Vec3::new(0.0, -h, -30.0), Vec3::new(px, py, z0)),
|
||||
facet(Vec3::new(0.0, h, -30.0), Vec3::new(px, py + pl, z0)),
|
||||
facet(Vec3::new(-h, 0.0, -50.0), Vec3::new(px, py, z0)),
|
||||
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(px + pw, py, z0)),
|
||||
];
|
||||
let mut hs = vec![
|
||||
HalfSpace { n: -Vec3::X, d: -px },
|
||||
HalfSpace { n: Vec3::X, d: px + pw },
|
||||
HalfSpace { n: -Vec3::Y, d: -py },
|
||||
HalfSpace { n: Vec3::Y, d: py + pl },
|
||||
HalfSpace { n: Vec3::Z, d: top + 10.0 },
|
||||
];
|
||||
hs.extend(facets);
|
||||
let pocket = convex_polyhedron(&hs).unwrap();
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(140.0, 90.0, top)).unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let pocket_vol = pw * pl * depth + pw * pl * h / 3.0;
|
||||
assert!(rel(measure(&basin).unwrap().volume, 140.0 * 90.0 * top - pocket_vol) < 1e-9);
|
||||
let s = shell(&basin, &[Vec3::new(5.0, 5.0, top)], t).unwrap();
|
||||
// The cavity's columns: from t up to the top, or up to the pocket floor moved down by t
|
||||
// inside the pocket's walls moved out by t.
|
||||
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d + t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
|
||||
let (nx, ny) = (1340, 840);
|
||||
let (dx, dy) = ((140.0 - 2.0 * t) / nx as f64, (90.0 - 2.0 * t) / ny as f64);
|
||||
let mut cavity = 0.0;
|
||||
for i in 0..nx {
|
||||
for j in 0..ny {
|
||||
let (x, y) = (t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy);
|
||||
let inside = x > px - t && x < px + pw + t && y > py - t && y < py + pl + t;
|
||||
let roof = if inside { floor(x, y) } else { top };
|
||||
cavity += (roof - t) * dx * dy;
|
||||
}
|
||||
}
|
||||
let want = 140.0 * 90.0 * top - pocket_vol - cavity;
|
||||
let m = measure(&s).unwrap();
|
||||
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
|
||||
}
|
||||
@@ -361,6 +361,10 @@ impl ConstraintKind {
|
||||
LinearDiameter { .. } => "LinearDiameterDimension",
|
||||
}
|
||||
}
|
||||
/// Does the constraint refer to point `p` (by index)?
|
||||
pub fn refers_to_point(&self, p: usize) -> bool {
|
||||
self.indices().0.contains(&p)
|
||||
}
|
||||
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
use ConstraintKind::*;
|
||||
match *self {
|
||||
@@ -892,6 +896,48 @@ impl Sketch {
|
||||
out
|
||||
}
|
||||
|
||||
/// Virtual sharp corners (what a fillet or chamfer leaves where dimensions measured to the
|
||||
/// corner): points used by no curve, held on two or more lines or arcs (their extensions).
|
||||
/// Each with the extension segments from the corner to the nearest end of those curves, for
|
||||
/// drawing.
|
||||
pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, p) in self.points.iter().enumerate().skip(1) {
|
||||
if self.point_used_by_curve(i) {
|
||||
continue;
|
||||
}
|
||||
let on: Vec<usize> = self
|
||||
.constraints
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
ConstraintKind::PointOnCurve { p, c } if p == i => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. })))
|
||||
.collect();
|
||||
if on.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut ext = Vec::new();
|
||||
for c in on {
|
||||
let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue };
|
||||
let (Some(pa), Some(pb)) = (self.point(a), self.point(b)) else { continue };
|
||||
let near = if p.pos.dist(pa) <= p.pos.dist(pb) { pa } else { pb };
|
||||
// A line only needs one when the corner is beyond its ends.
|
||||
let inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && {
|
||||
let d = pb - pa;
|
||||
let t = (p.pos - pa).dot(d) / d.dot(d).max(1e-300);
|
||||
(0.0..=1.0).contains(&t)
|
||||
};
|
||||
if !inside && p.pos.dist(near) > 1e-9 {
|
||||
ext.push((p.pos, near));
|
||||
}
|
||||
}
|
||||
out.push((i, ext));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is curve `c` held in place: fixed or projected (linked).
|
||||
pub fn curve_locked(&self, c: usize) -> bool {
|
||||
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
|
||||
|
||||
@@ -2112,6 +2112,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let sk = &ss.sketch;
|
||||
let locked = sk.locked_points();
|
||||
let virtual_corners: Vec<usize> = sk.virtual_corners().into_iter().map(|(i, _)| i).collect();
|
||||
for (i, p) in sk.points.iter().enumerate() {
|
||||
if !solvecraft_engine::view::sketch_point_visible(sk, i) {
|
||||
continue;
|
||||
@@ -2128,6 +2129,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
};
|
||||
if i == 0 {
|
||||
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge));
|
||||
} else if virtual_corners.contains(&i) {
|
||||
// A virtual sharp corner: an open square, it is not on any drawn curve.
|
||||
painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user