Keyboard-first commands: E/F/H open with the on-canvas value box focused as soon as the geometry is picked (typing replaces the value, Enter applies, Tab moves on, Esc cancels), extrude direction arrow; Extrude takes planar body faces (face: [x,y,z] copies the face boundary into a sketch), so select-face-then-E press-pulls; join/cut previews show the tool body (cut see-through red)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
d44083d1cc
commit
7ef3426cb3
11 files changed
+433
-43
No files matched your search
@@ -0,0 +1,128 @@
|
||||
//! Sketches made from a planar body face, so a face can be extruded like a profile (Extrude
|
||||
//! with `face`, Press Pull). The face boundary is copied into a new sketch on the face: straight
|
||||
//! edges become lines, circular edges circles or arcs, anything else a chain of lines.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::{Mesh, Vec2, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return f64::INFINITY };
|
||||
let h = (p - a).dot(nn);
|
||||
let q = p - nn * h;
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12);
|
||||
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64)> = None;
|
||||
for b in &st.bodies {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let d = point_triangle_dist(p, a, bb, c);
|
||||
if near.is_none_or(|(_, nd)| d < nd) {
|
||||
near = Some((*f, d));
|
||||
}
|
||||
}
|
||||
if let Some((f, d)) = near
|
||||
&& best.as_ref().is_none_or(|x| d < x.2)
|
||||
{
|
||||
best = Some((m.clone(), f, d));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
|
||||
let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
||||
if d.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (a2, b2, c2) = (a.len2(), b.len2(), c.len2());
|
||||
let o = Vec2::new((a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d);
|
||||
let r = o.dist(a);
|
||||
(o.is_finite() && r.is_finite() && r < 1e8).then_some((o, r))
|
||||
}
|
||||
|
||||
fn xy(v: Vec2) -> Value {
|
||||
json!([v.x, v.y])
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
}
|
||||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect();
|
||||
let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
if tris.iter().any(|[a, b, c]| (*b - *a).cross(*c - *a).normalized().is_some_and(|n| n.dot(normal) < 1.0 - 1e-6)) {
|
||||
return Err(bad(cmd, "only planar faces can be extruded"));
|
||||
}
|
||||
// A point well inside the face: the centroid of its largest triangle.
|
||||
let inside = tris
|
||||
.iter()
|
||||
.max_by(|x, y| (x[1] - x[0]).cross(x[2] - x[0]).len().total_cmp(&(y[1] - y[0]).cross(y[2] - y[0]).len()))
|
||||
.map(|[a, b, c]| (*a + *b + *c) / 3.0)
|
||||
.unwrap_or(point);
|
||||
let edges: Vec<Vec<Vec3>> =
|
||||
m.face_edges(f).into_iter().filter(|e| !m.seams.get(*e).copied().unwrap_or(false)).filter_map(|e| m.edges.get(e).cloned()).collect();
|
||||
if edges.is_empty() || edges.len() > 5000 {
|
||||
return Err(bad(cmd, "the face has no usable boundary"));
|
||||
}
|
||||
let run = |s: &mut Session, id: &str, p: Value| -> Result<Value> {
|
||||
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
|
||||
(spec.run)(s, &p)
|
||||
};
|
||||
let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}}))?;
|
||||
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue };
|
||||
let closed = pts.len() > 3 && first.dist(last) < tol;
|
||||
let circle = if pts.len() >= 4 {
|
||||
let n = pts.len() - usize::from(closed);
|
||||
let (a, b, c) = (pts.first(), pts.get(n / 3), pts.get(2 * n / 3));
|
||||
match (a, b, c) {
|
||||
(Some(a), Some(b), Some(c)) => circumcircle(*a, *b, *c).filter(|(o, r)| pts.iter().all(|q| (q.dist(*o) - r).abs() < tol)),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match circle {
|
||||
Some((o, r)) if closed => run(s, "CircleCenterRadius", json!({"center": xy(o), "radius": r}))?,
|
||||
Some(_) => {
|
||||
let mid = pts.get(pts.len() / 2).copied().unwrap_or(first);
|
||||
run(s, "ArcThreePoint", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
|
||||
}
|
||||
None => {
|
||||
let mut list: Vec<Value> = Vec::new();
|
||||
for q in &pts {
|
||||
// Drop points that repeat (a closed polyline ends where it starts).
|
||||
if list.last().is_none_or(|l| l != &xy(*q)) {
|
||||
list.push(xy(*q));
|
||||
}
|
||||
}
|
||||
let closed_poly = list.len() > 2 && list.first() == list.last();
|
||||
if closed_poly {
|
||||
list.pop();
|
||||
}
|
||||
run(s, "DrawPolyline", json!({"points": list, "closed": closed_poly}))?
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(inside)))
|
||||
}
|
||||
@@ -13,7 +13,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] | [[curve ids]] | [{point:[x,y]}]; direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("Revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -178,7 +178,14 @@ fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
|
||||
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "Extrude";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
|
||||
let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
@@ -202,7 +209,10 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: profiles(p, cmd)?,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! as the parity metric (`cargo xtask parity`).
|
||||
|
||||
mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
mod file;
|
||||
mod inspect;
|
||||
|
||||
Reference in new issue
Block a user