Compare commits

...
7 changed files with 147 additions and 17 deletions

No files matched your search

+14 -1
View File
@@ -176,6 +176,15 @@ pub enum FeatureKind {
Chamfer { Chamfer {
edges: Vec<Vec3>, edges: Vec<Vec3>,
distance: String, distance: String,
/// Unequal chamfers: the distance along the second face…
#[serde(default, skip_serializing_if = "Option::is_none")]
distance2: Option<String>,
/// …or the angle the chamfer makes with the first face.
#[serde(default, skip_serializing_if = "Option::is_none")]
angle: Option<String>,
/// Swap which face is the first (by default the one facing up most).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>, body: Option<String>,
}, },
@@ -923,7 +932,11 @@ impl FeatureKind {
} }
FeatureKind::Revolve { angle, .. } => v.push(angle), FeatureKind::Revolve { angle, .. } => v.push(angle),
FeatureKind::Fillet { radius, .. } => v.push(radius), FeatureKind::Fillet { radius, .. } => v.push(radius),
FeatureKind::Chamfer { distance, .. } => v.push(distance), FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(distance);
v.extend(distance2.iter().map(String::as_str));
v.extend(angle.iter().map(String::as_str));
}
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]), FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]),
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]), FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]),
FeatureKind::Sphere { radius, .. } => v.push(radius), FeatureKind::Sphere { radius, .. } => v.push(radius),
+29 -9
View File
@@ -1726,10 +1726,9 @@ fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
let moved: Vec<Vec3> = edges.iter().map(|p| crate::apply_point(m, *p)).collect(); let moved: Vec<Vec3> = edges.iter().map(|p| crate::apply_point(m, *p)).collect();
let ti = blend_target(st, &None, &moved)?; let ti = blend_target(st, &None, &moved)?;
let Some(mb) = st.bodies.get(ti).cloned() else { continue }; let Some(mb) = st.bodies.get(ti).cloned() else { continue };
let nb = if matches!(src.kind, FeatureKind::Fillet { .. }) { let nb = match &src.kind {
kernel::fillet(&mb.body, &moved, r)? FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
} else { _ => kernel::fillet(&mb.body, &moved, r)?,
kernel::chamfer(&mb.body, &moved, r)?
}; };
if let Some(slot) = st.bodies.get_mut(ti) { if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::new(mb.name, nb, mb.feature); *slot = ModelBody::new(mb.name, nb, mb.feature);
@@ -1819,6 +1818,27 @@ fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) ->
} }
} }
/// A chamfer, equal or by its second distance or angle.
fn chamfer_with(
vals: &BTreeMap<String, Value>,
b: &kernel::Body,
edges: &[Vec3],
d: f64,
distance2: &Option<String>,
angle: &Option<String>,
flip: bool,
) -> Result<kernel::Body> {
let side = match (distance2, angle) {
(Some(e), _) => Some(kernel::ChamferSide::Distance(val(vals, e, Kind::Length)?)),
(None, Some(e)) => Some(kernel::ChamferSide::Angle(val(vals, e, Kind::Angle)?)),
(None, None) => None,
};
Ok(match side {
Some(s) => kernel::chamfer_sides(b, edges, d, s, flip)?,
None => kernel::chamfer(b, edges, d)?,
})
}
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> { fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind { match &f.kind {
FeatureKind::SheetBase { .. } FeatureKind::SheetBase { .. }
@@ -2000,7 +2020,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} }
Ok(()) Ok(())
} }
FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body } => { FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => {
let r = val(vals, radius, Kind::Length)?; let r = val(vals, radius, Kind::Length)?;
let ti = blend_target(st, body, edges)?; let ti = blend_target(st, body, edges)?;
let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) }; let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) };
@@ -2042,10 +2062,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} else { } else {
resolve_edges(&mb.body, edges, &f.edge_refs, warning)? resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
}; };
let nb = if matches!(f.kind, FeatureKind::Fillet { .. }) { let nb = match &f.kind {
kernel::fillet(&mb.body, &edges, r)? FeatureKind::Fillet { .. } => kernel::fillet(&mb.body, &edges, r)?,
} else { FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
kernel::chamfer(&mb.body, &edges, r)? _ => return Err(DocError::Invalid("blend".into())),
}; };
let (name, feat) = (mb.name.clone(), mb.feature); let (name, feat) = (mb.name.clone(), mb.feature);
if let Some(slot) = st.bodies.get_mut(ti) { if let Some(slot) = st.bodies.get_mut(ti) {
+9 -1
View File
@@ -49,7 +49,15 @@ impl FeatureKind {
} }
FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)), FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)),
FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)), FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)),
FeatureKind::Chamfer { distance, .. } => v.push(("Distance", distance, L)), FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(("Distance", distance, L));
if let Some(d) = distance2 {
v.push(("Distance 2", d, L));
}
if let Some(a) = angle {
v.push(("Angle", a, A));
}
}
FeatureKind::Box { length, width, height, .. } => { FeatureKind::Box { length, width, height, .. } => {
v.push(("Length", length, L)); v.push(("Length", length, L));
v.push(("Width", width, L)); v.push(("Width", width, L));
+13 -2
View File
@@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("fillet") .icon("fillet")
.key("F") .key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"), .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"),
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; body?"), CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"), CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect) CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
@@ -497,7 +497,18 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let edges = edge_points(s, p, cmd)?; let edges = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?; let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?; check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::Chamfer { edges, distance, body: str_(p, "body").map(str::to_string) }) let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
if let Some(d) = &distance2 {
check_expr(s, d, Kind::Length, cmd, "distance2")?;
}
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
if distance2.is_some() && angle.is_some() {
return Err(bad(cmd, "give `distance2` or `angle`, not both"));
}
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(s, p, FeatureKind::Chamfer { edges, distance, distance2, angle, flip, body: str_(p, "body").map(str::to_string) })
} }
fn combine(s: &mut Session, p: &Value) -> Result<Value> { fn combine(s: &mut Session, p: &Value) -> Result<Value> {
+15
View File
@@ -1412,3 +1412,18 @@ fn press_pull_on_curved_faces() {
let v1 = volume(&mut s); let v1 = volume(&mut s);
assert!(rel(v1 - v0, PI * (25.0 - 16.0) * 20.0) < 1e-3, "{v0} {v1}"); assert!(rel(v1 - v0, PI * (25.0 - 16.0) * 20.0) < 1e-3, "{v0} {v1}");
} }
/// Chamfers with two distances or a distance and an angle.
#[test]
fn unequal_chamfers() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
// Top front edge: 2 along the top (the face facing up), 4 down the front.
run(&mut s, "FusionChamferCommand", json!({"edges": [[20, 0, 20]], "distance": 2, "distance2": 4}));
assert!(rel(volume(&mut s), 24000.0 - 0.5 * 2.0 * 4.0 * 40.0) < 1e-6, "{}", volume(&mut s));
// Bottom back edge: 3 along the bottom, at 30° from it (down the back face 3·tan 30°).
run(&mut s, "FusionChamferCommand", json!({"edges": [[20, 30, 0]], "distance": 3, "angle": "30 deg", "flip": true}));
let d2 = 3.0 * (30f64).to_radians().tan();
assert!(rel(volume(&mut s), 24000.0 - 160.0 - 0.5 * 3.0 * d2 * 40.0) < 1e-6, "{}", volume(&mut s));
assert!(s.execute("FusionChamferCommand", &json!({"edges": [[0, 15, 20]], "distance": 1, "distance2": 2, "angle": "30 deg"})).is_err());
}
+66 -3
View File
@@ -762,16 +762,79 @@ fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
/// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that /// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that
/// stay outside the body so that neighbouring cutters don't share edges. /// stay outside the body so that neighbouring cutters don't share edges.
/// The second side of an unequal chamfer: a distance along the other face, or the angle the
/// chamfer makes with the first face.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ChamferSide {
Distance(f64),
Angle(f64),
}
/// A chamfer with different setbacks on the two faces of each (straight) edge: `d1` along the
/// first face (the one facing up most, the other with `flip`), the second side by distance or
/// angle.
pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, flip: bool) -> Result<Body> {
body.require_brep("chamfer")?;
let ok = |x: f64| x.is_finite() && x > 1e-6 && x < 1e6;
let valid = ok(d1)
&& match side {
ChamferSide::Distance(d) => ok(d),
ChamferSide::Angle(a) => a.is_finite() && a > 1e-3 && a < std::f64::consts::PI - 1e-3,
};
if !valid {
return Err(KernelError::Invalid("chamfer sizes must be positive (the angle within 0°…180°)".into()));
}
if edges.is_empty() || edges.len() > 1000 {
return Err(KernelError::Invalid("select 1…1000 edges".into()));
}
let mut cur = body.clone();
for (k, p) in edges.iter().enumerate() {
let t = chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k)?;
cur = crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
}
Ok(cur)
}
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> { pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> {
let f = edge_frame(cur, p)?; chamfer_tool_sides(cur, p, s, None, k)
}
fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result<Body> {
let mut f = edge_frame(cur, p)?;
// Setbacks along each face: equal, or by the second side's rule.
let (s1, s2) = match sides {
None => (s, s),
Some((side, flip)) => {
// Side 1: the face facing up most (then +y, +x), unless flipped.
let up = Vec3::new(1e-6, 1e-3, 1.0);
if (f.n2.dot(up) > f.n1.dot(up)) != flip {
std::mem::swap(&mut f.n1, &mut f.n2);
std::mem::swap(&mut f.t1, &mut f.t2);
}
let s2 = match side {
ChamferSide::Distance(d) => d,
ChamferSide::Angle(a) => {
// The triangle edge–A–B: angle φ at the edge, `a` at A (on face 1).
let phi = f.t1.dot(f.t2).clamp(-1.0, 1.0).acos();
let den = (phi + a).sin();
if den <= 1e-9 {
return Err(KernelError::Invalid("that chamfer angle runs past the other face".into()));
}
s * a.sin() / den
}
};
(s, s2)
}
};
let s = s1.max(s2);
let size = cur.size(); let size = cur.size();
let len = f.p0.dist(f.p1); let len = f.p0.dist(f.p1);
let ext = (s * 2.0).max(size * 0.01) * (1.0 + 0.093 * (k % 5) as f64); let ext = (s * 2.0).max(size * 0.01) * (1.0 + 0.093 * (k % 5) as f64);
// Cross-section at p0 - d*ext, in a plane with normal d. // Cross-section at p0 - d*ext, in a plane with normal d.
let base = f.p0 - f.d * ext; let base = f.p0 - f.d * ext;
let plane = solvecraft_geom::Plane::from_normal(base, f.d).ok_or_else(|| KernelError::Failed("chamfer plane".into()))?; let plane = solvecraft_geom::Plane::from_normal(base, f.d).ok_or_else(|| KernelError::Failed("chamfer plane".into()))?;
let a = base + f.t1 * s; let a = base + f.t1 * s1;
let b = base + f.t2 * s; let b = base + f.t2 * s2;
let ab = (a - b).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?; let ab = (a - b).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?;
let out = (f.n1 + f.n2).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?; let out = (f.n1 + f.n2).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?;
let vary = 1.0 + 0.137 * (k % 7) as f64; let vary = 1.0 + 0.137 * (k % 7) as f64;
+1 -1
View File
@@ -35,7 +35,7 @@ mod step_out;
mod sweep_path; mod sweep_path;
mod topo; mod topo;
pub use blend::{chamfer, fillet}; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use helix::{loft_to_point, sweep_helix}; pub use helix::{loft_to_point, sweep_helix};