Sweeps and pipes along 3D sketch curves (projected, intersection and included curves): a path that uses sketch wires is followed as one chain of points by a new kernel sweep_path (rotation-minimising frame; smooth B-spline walls per profile segment, or ruled walls mitred at sharp corners and for profiles with holes); 3D sketch curves can be picked as sweep paths

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 09:42:34 +00:00
1 parent 3aacebdda4
commit f79b5c1045
6 files changed
+487 -13

No files matched your search

+89 -12
View File
@@ -1041,6 +1041,10 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| kernel::sweep_path(&ss.plane, r, &pts).map_err(DocError::from)).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).collect()
}
@@ -1051,6 +1055,12 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
if !(r > 1e-6) {
return Err(DocError::Invalid("pipe diameter must be positive".into()));
}
if let Some(pts) = path_points(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied()) else { return Err(DocError::Invalid("empty path".into())) };
let t = (b - a).normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
let plane = Plane::from_normal(a, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
return Ok(vec![kernel::sweep_path(&plane, &pipe_region(vals, r, wall)?, &pts)?]);
}
let segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
let first = segs.first().ok_or_else(|| DocError::Invalid("empty path".into()))?;
let (start, tangent) = match *first {
@@ -1059,18 +1069,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
};
let t = tangent.normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
let holes = match wall {
Some(w) => {
let wt = val(vals, w, Kind::Length)?;
if !(wt > 0.0 && wt < r) {
return Err(DocError::Invalid("the wall must be thinner than the radius".into()));
}
vec![solvecraft_geom::Loop2::circle(Vec2::ZERO, r - wt).reversed()]
}
None => Vec::new(),
};
let region = Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes };
Ok(vec![kernel::sweep(&plane, &region, &segs)?])
Ok(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?])
}
FeatureKind::Box { corner, length, width, height, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
@@ -1099,6 +1098,84 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
}
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
let holes = match wall {
Some(w) => {
let wt = val(vals, w, Kind::Length)?;
if !(wt > 0.0 && wt < r) {
return Err(DocError::Invalid("the wall must be thinner than the radius".into()));
}
vec![solvecraft_geom::Loop2::circle(Vec2::ZERO, r - wt).reversed()]
}
None => Vec::new(),
};
Ok(Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
}
/// A path that uses 3D sketch curves (wires), as one chain of world points from the end nearest
/// `start` (no start: the first curve runs toward the second). `None` when the path is all
/// planar curves.
fn path_points(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Option<Vec<Vec3>>> {
if !ids.iter().any(|id| ps.sketch.wire_index(id).is_some()) {
return Ok(None);
}
if ids.len() > 1000 {
return Err(DocError::Invalid("the path needs 1…1000 curves".into()));
}
let mut pieces: Vec<Vec<Vec3>> = Vec::with_capacity(ids.len());
for id in ids {
if let Some(wi) = ps.sketch.wire_index(id) {
pieces.push(ps.sketch.wires.get(wi).map(|w| w.pts.clone()).unwrap_or_default());
} else {
let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?;
let mut pts: Vec<Vec3> = Vec::new();
for s in ps.sketch.segs(ci) {
for q in s.polyline(1e-2) {
let w = ps.plane.to_world(q);
if pts.last().is_none_or(|l| l.dist(w) > 1e-9) {
pts.push(w);
}
}
}
pieces.push(pts);
}
}
// Pieces meet within the sketch's tolerance (projected curves are only that close).
let tol = 1e-3;
let ends = |p: &Vec<Vec3>| (p.first().copied().unwrap_or_default(), p.last().copied().unwrap_or_default());
if let Some(f) = pieces.first().cloned() {
let (a, b) = ends(&f);
let flip = if start.is_finite() {
b.dist(start) < a.dist(start)
} else {
pieces.get(1).is_some_and(|n| {
let (na, nb) = ends(n);
na.dist(a).min(nb.dist(a)) < na.dist(b).min(nb.dist(b))
})
};
if flip && let Some(p0) = pieces.first_mut() {
p0.reverse();
}
}
let mut chain: Vec<Vec3> = Vec::new();
for mut p in pieces {
if let Some(last) = chain.last().copied() {
let (a, b) = ends(&p);
if b.dist(last) < a.dist(last) {
p.reverse();
}
if p.first().is_some_and(|a| a.dist(last) > tol) {
return Err(DocError::Invalid("the path curves are not connected end to end".into()));
}
chain.extend(p.into_iter().skip(1));
} else {
chain = p;
}
}
Ok(Some(chain))
}
fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<kernel::PathSeg>> {
if ids.is_empty() || ids.len() > 1000 {
return Err(DocError::Invalid("the path needs 1…1000 curves".into()));
@@ -350,3 +350,48 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
assert_eq!(c["type"], "spline", "{c}");
assert_eq!(c["points"].as_array().unwrap().len(), 3);
}
#[test]
fn sweeps_and_pipes_follow_3d_sketch_curves() {
// A line projected onto a sphere: a 3D arc on its top.
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let path_sketch = s.active_sketch.unwrap();
let pts = {
let st = s.model.state();
let sk = &st.sketch(path_sketch).unwrap().sketch;
sk.wires[sk.wire_index(&wid).unwrap()].pts.clone()
};
run(&mut s, "SketchStop", json!({}));
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
// A pipe along it: volume ≈ π r² × length.
let before = s.model.state().bodies.len();
run(&mut s, "PrimitivePipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let v = st.bodies.last().unwrap().mesh().measure().volume;
let want = std::f64::consts::PI * len;
assert!((v - want).abs() < 0.05 * want, "{v} vs {want}");
drop(st);
// A sweep of a square profile drawn square to the curve at its start.
let (a, b) = (pts[0], pts[1]);
let t = (b - a).normalized().unwrap();
let x = t.cross(solvecraft_geom::Vec3::Z).normalized().unwrap();
let y = t.cross(x);
run(&mut s, "SketchCreate", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-1, -1], "p1": [1, 1]}));
let prof = s.active_sketch.unwrap();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 2);
let v = st.bodies.last().unwrap().mesh().measure().volume;
assert!((v - 4.0 * len).abs() < 0.05 * 4.0 * len, "{v} vs {}", 4.0 * len);
}
+2
View File
@@ -32,6 +32,7 @@ mod sew;
mod step;
mod step_in;
mod step_out;
mod sweep_path;
mod topo;
pub use blend::{chamfer, fillet};
@@ -47,6 +48,7 @@ pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
+329
View File
@@ -0,0 +1,329 @@
//! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves).
//! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline
//! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls
//! between stations, mitred at the corners.
use mt::builder;
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, p3};
use crate::build::{cap_and_close, interpolate_cubic, uniform_wire};
use crate::{KernelError, Result, guard};
/// Most path points accepted.
const MAX_PATH_POINTS: usize = 100_000;
/// Stations of a smooth sweep (the B-spline walls interpolate them).
const MAX_SMOOTH_STATIONS: usize = 120;
/// Stations of a ruled sweep.
const MAX_RULED_STATIONS: usize = 2000;
/// A turn sharper than this between path segments is a corner (radians).
const CORNER: f64 = 0.35;
/// Samples per profile segment of the smooth walls.
const SEG_SAMPLES: usize = 12;
/// A linear map of world vectors (columns) plus the station it is centred at.
#[derive(Clone, Copy)]
struct Station {
at: Vec3,
m: [Vec3; 3],
}
impl Station {
fn apply(&self, v: Vec3) -> Vec3 {
self.m[0] * v.x + self.m[1] * v.y + self.m[2] * v.z
}
}
fn clean(path: &[Vec3]) -> Result<Vec<Vec3>> {
if path.len() < 2 || path.len() > MAX_PATH_POINTS {
return Err(KernelError::Invalid("a sweep path needs 2…100000 points".into()));
}
let mut out: Vec<Vec3> = Vec::with_capacity(path.len());
for p in path {
if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) {
return Err(KernelError::Invalid("the sweep path has a bad point".into()));
}
if out.last().is_none_or(|l| l.dist(*p) > 1e-9) {
out.push(*p);
}
}
if out.len() < 2 {
return Err(KernelError::Invalid("the sweep path has no length".into()));
}
Ok(out)
}
fn turn(a: Vec3, b: Vec3) -> f64 {
match (a.normalized(), b.normalized()) {
(Some(a), Some(b)) => a.dot(b).clamp(-1.0, 1.0).acos(),
_ => 0.0,
}
}
/// Points evenly spaced by arc length along the polyline (ends kept).
fn resample(p: &[Vec3], n: usize) -> Vec<Vec3> {
let mut cum = vec![0.0];
for w in p.windows(2) {
let l = cum.last().copied().unwrap_or(0.0) + w[0].dist(w[1]);
cum.push(l);
}
let total = cum.last().copied().unwrap_or(0.0);
let mut out = Vec::with_capacity(n + 1);
let mut k = 0;
for i in 0..=n {
let s = total * i as f64 / n as f64;
while k + 2 < cum.len() && cum.get(k + 1).is_some_and(|c| *c < s) {
k += 1;
}
let (Some(a), Some(b), Some(ca), Some(cb)) = (p.get(k), p.get(k + 1), cum.get(k), cum.get(k + 1)) else { break };
let t = if cb - ca > 1e-15 { ((s - ca) / (cb - ca)).clamp(0.0, 1.0) } else { 0.0 };
out.push(*a + (*b - *a) * t);
}
out
}
/// Rotation-minimising frames (double reflection) at the points, for the given unit tangents;
/// each station maps the first frame onto its own.
fn frames(pts: &[Vec3], tangents: &[Vec3]) -> Option<Vec<[Vec3; 3]>> {
let t0 = *tangents.first()?;
let helper = if t0.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
let mut r = t0.cross(helper).normalized()?;
let mut out = vec![[t0, r, t0.cross(r)]];
for i in 0..pts.len().saturating_sub(1) {
let (x0, x1, ti, tn) = (*pts.get(i)?, *pts.get(i + 1)?, *tangents.get(i)?, *tangents.get(i + 1)?);
let v1 = x1 - x0;
let c1 = v1.dot(v1);
let (rl, tl) = if c1 > 1e-24 { (r - v1 * (2.0 / c1 * v1.dot(r)), ti - v1 * (2.0 / c1 * v1.dot(ti))) } else { (r, ti) };
let v2 = tn - tl;
let c2 = v2.dot(v2);
r = if c2 > 1e-24 { rl - v2 * (2.0 / c2 * v2.dot(rl)) } else { rl };
// Keep it exactly perpendicular and unit.
r = (r - tn * r.dot(tn)).normalized()?;
out.push([tn, r, tn.cross(r)]);
}
Some(out)
}
/// Stations: each maps a world vector at the first station to the same vector at its own,
/// optionally stretched across a corner (mitre).
fn stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>]) -> Result<Vec<Station>> {
let f = frames(pts, tangents).ok_or_else(|| KernelError::Invalid("the sweep path doubles back".into()))?;
let f0 = *f.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?;
let mut out = Vec::with_capacity(f.len());
for (i, fi) in f.iter().enumerate() {
// Rotation taking f0 to fi: columns are the images of the world axes.
let col = |e: Vec3| fi[0] * f0[0].dot(e) + fi[1] * f0[1].dot(e) + fi[2] * f0[2].dot(e);
let mut m = [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)];
if let Some(Some((b, s))) = mitre.get(i) {
for c in &mut m {
*c = *c + *b * ((s - 1.0) * c.dot(*b));
}
}
out.push(Station { at: *pts.get(i).ok_or_else(|| KernelError::Invalid("sweep path".into()))?, m });
}
Ok(out)
}
/// The profile plane carried to a station.
fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
Plane { origin: st.at + st.apply(plane.origin - s0), x: st.apply(plane.x), y: st.apply(plane.y) }
}
/// Sweep a planar region along a 3D polyline path. The profile keeps its relation to the path
/// (it need not touch it); the path should start near the profile.
pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body> {
if region.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
let pts = clean(path)?;
let corner = pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER);
let lp = region.outer.ccw();
if !corner && region.holes.is_empty() {
// Smooth: evenly spaced stations, tangents from neighbours.
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
let sp = resample(&pts, n);
let tangents = central_tangents(&sp)?;
let st = stations(&sp, &tangents, &[])?;
let expected = region.area().abs() * len * start_cos(plane, &tangents);
if let Ok(b) = smooth_walls(plane, &lp, &st, expected) {
return Ok(b);
}
}
ruled(plane, region, &pts)
}
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
let n = p.len();
(0..n)
.map(|i| {
let a = p.get(i.saturating_sub(1)).copied().unwrap_or_default();
let b = p.get((i + 1).min(n - 1)).copied().unwrap_or_default();
(b - a).normalized().ok_or_else(|| KernelError::Invalid("the sweep path has no direction".into()))
})
.collect()
}
/// How squarely the path leaves the profile (volume = area × length × this).
fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
tangents.first().map(|t| t.dot(plane.normal()).abs()).unwrap_or(1.0).max(1e-3)
}
/// Ruled walls between stations at the path's points (mitred at corners), every loop.
fn ruled(plane: &Plane, region: &Region2, pts: &[Vec3]) -> Result<Body> {
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() };
let n = pts.len();
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
let mut tangents = Vec::with_capacity(n);
let mut mitre = Vec::with_capacity(n);
for i in 0..n {
let d_in = if i > 0 { seg_dir(i - 1) } else { None };
let d_out = if i + 1 < n { seg_dir(i) } else { None };
let (t, m) = match (d_in, d_out) {
(Some(a), Some(b)) => {
let t = (a + b).normalized().ok_or_else(|| KernelError::Invalid("the sweep path turns back on itself".into()))?;
let half = turn(a, b) / 2.0;
// Across the bend the section stretches by 1 / cos(half the turn).
let m = (b - a).normalized().map(|bend| (bend, 1.0 / half.cos().max(0.2)));
(t, m)
}
(Some(a), None) => (a, None),
(None, Some(b)) => (b, None),
(None, None) => return Err(KernelError::Invalid("sweep path".into())),
};
tangents.push(t);
mitre.push(m);
}
let st = stations(&pts, &tangents, &mitre)?;
let s0 = pts.first().copied().unwrap_or_default();
let loops: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
for s in &st {
let pl = station_plane(plane, s0, s);
wires.push(loops.iter().map(|l| uniform_wire(&pl, l)).collect::<Result<Vec<_>>>()?);
}
guard("path sweep", || {
let mut faces: Vec<mt::Face> = Vec::new();
for w in wires.windows(2) {
for (a, b) in w[0].iter().zip(&w[1]) {
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("path sweep: {e}")))?;
faces.extend(sides.face_iter().cloned());
}
}
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) };
cap_and_close(faces, first, last)
})
}
/// One B-spline wall per profile segment through the stations, capped; checked by volume.
fn smooth_walls(plane: &Plane, lp: &Loop2, st: &[Station], expected: f64) -> Result<Body> {
let nseg = lp.segs.len();
let fail = |w: &str| KernelError::Failed(format!("path sweep: {w}"));
if nseg == 0 || nseg > 64 || st.len() < 4 {
return Err(fail("profile"));
}
let s0 = st.first().map(|s| s.at).unwrap_or_default();
let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default();
let n = st.len() - 1;
let mut data = Vec::with_capacity(nseg);
for s in &lp.segs {
let mut station_curves = Vec::with_capacity(n + 1);
for i in 0..=n {
let pts: Vec<Vec3> = (0..SEG_SAMPLES).map(|j| at(i, s.point_at(j as f64 / (SEG_SAMPLES - 1) as f64))).collect();
station_curves.push(interpolate_cubic(&pts).ok_or_else(|| fail("profile curve"))?);
}
let kv = station_curves.first().map(|c| c.knot_vec().clone()).ok_or_else(|| fail("no stations"))?;
let mut columns = Vec::with_capacity(SEG_SAMPLES);
for j in 0..SEG_SAMPLES {
let pts: Vec<Vec3> = station_curves
.iter()
.map(|c| {
let p = c.control_point(j);
Vec3::new(p.x, p.y, p.z)
})
.collect();
columns.push(interpolate_cubic(&pts).ok_or_else(|| fail("path curve"))?);
}
let ku = columns.first().map(|c| c.knot_vec().clone()).ok_or_else(|| fail("no columns"))?;
let net: Vec<Vec<mt::Point3>> = (0..=n).map(|m| columns.iter().map(|c| *c.control_point(m)).collect()).collect();
let surf = mt::BSplineSurface::try_new((ku, kv), net).map_err(|e| fail(&e.to_string()))?;
let (Some(first), Some(last)) = (station_curves.first().cloned(), station_curves.last().cloned()) else { return Err(fail("stations")) };
let seam = columns.first().cloned().ok_or_else(|| fail("seam"))?;
data.push((first, last, seam, surf));
}
guard("path sweep", || {
let start: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(at(0, s.start())))).collect();
let end: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(at(n, s.start())))).collect();
let (mut e0, mut e1, mut seams) = (Vec::with_capacity(nseg), Vec::with_capacity(nseg), Vec::with_capacity(nseg));
for (j, (c0, c1, seam, _)) in data.iter().enumerate() {
let (Some(a0), Some(b0), Some(a1), Some(b1)) = (start.get(j), start.get((j + 1) % nseg), end.get(j), end.get((j + 1) % nseg)) else {
return Err(fail("vertices"));
};
e0.push(mt::Edge::try_new(a0, b0, mt::Curve::BSplineCurve(c0.clone())).map_err(|e| fail(&e.to_string()))?);
e1.push(mt::Edge::try_new(a1, b1, mt::Curve::BSplineCurve(c1.clone())).map_err(|e| fail(&e.to_string()))?);
seams.push(mt::Edge::try_new(a0, a1, mt::Curve::BSplineCurve(seam.clone())).map_err(|e| fail(&e.to_string()))?);
}
let mut walls = Vec::with_capacity(nseg);
for (j, (_, _, _, surf)) in data.iter().enumerate() {
let (Some(a), Some(b), Some(sa), Some(sb)) = (e0.get(j), e1.get(j), seams.get(j), seams.get((j + 1) % nseg)) else {
return Err(fail("edges"));
};
let w: mt::Wire = vec![sa.clone(), b.clone(), sb.inverse(), a.inverse()].into();
walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?);
}
let w0: mt::Wire = e0.iter().cloned().collect();
let w1: mt::Wire = e1.iter().cloned().collect();
let tol = (lp.signed_area().abs().sqrt() * 0.05).max(1e-3);
for ws in [walls.clone(), walls.iter().map(|f| f.inverse()).collect()] {
if let Ok(b) = cap_and_close(ws, std::slice::from_ref(&w0), std::slice::from_ref(&w1))
&& b.tessellate(tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs())
{
return Ok(b);
}
}
Err(fail("the walls do not close a solid"))
})
}
#[cfg(test)]
mod tests {
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use super::sweep_path;
fn circle(r: f64) -> Region2 {
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
}
#[test]
fn sweeps_along_3d_paths() {
// A quarter of a helix-like 3D curve: a smooth wall, volume ≈ area × length.
let path: Vec<Vec3> = (0..=40)
.map(|i| {
let t = i as f64 / 40.0 * std::f64::consts::FRAC_PI_2;
Vec3::new(30.0 * t.sin(), 30.0 * (1.0 - t.cos()), 10.0 * t)
})
.collect();
let t0 = (path[1] - path[0]).normalized().unwrap();
let plane = Plane::from_normal(path[0], t0).unwrap();
let b = sweep_path(&plane, &circle(2.0), &path).unwrap();
let len: f64 = path.windows(2).map(|w| w[0].dist(w[1])).sum();
let v = b.tessellate(0.01).unwrap().measure().volume;
let want = std::f64::consts::PI * 4.0 * len;
assert!((v - want).abs() < 0.02 * want, "{v} vs {want}");
// One wall per profile segment and two caps (ruled walls would give dozens).
assert!(b.faces(0.01).unwrap().len() <= 8, "smooth walls: {}", b.faces(0.01).unwrap().len());
// An L-shaped path (a sharp corner) with a holed square: mitred ruled walls.
let path = [Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::new(20.0, 0.0, 20.0)];
let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap();
let sq = |h: f64| Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]);
let region = Region2 { outer: sq(3.0), holes: vec![sq(1.0).reversed()] };
let b = sweep_path(&plane, &region, &path).unwrap();
let v = b.tessellate(0.01).unwrap().measure().volume;
let want = 32.0 * 40.0;
assert!((v - want).abs() < 0.03 * want, "{v} vs {want}");
assert!(sweep_path(&plane, &region, &[Vec3::ZERO]).is_err());
assert!(sweep_path(&plane, &region, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
}
}
+2 -1
View File
@@ -2009,7 +2009,8 @@ fn source_features(s: &Session, bodies: &[String]) -> Vec<String> {
/// The sketch holding all these curve ids (the active sketch first, never `not`).
fn curves_sketch(app: &SolveApp, ids: &[String], not: Option<u64>) -> Option<u64> {
let st = app.session.model.state();
let has = |sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some()));
let has =
|sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some() || ss.sketch.wire_index(id).is_some()));
if let Some(a) = app.session.active_sketch
&& Some(a) != not
&& has(a)
+20
View File
@@ -483,6 +483,16 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
}
}
}
// 3D sketch curves (paths for sweeps and pipes).
for w in &ss.sketch.wires {
let pts: Vec<Pos2> = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect();
for seg in pts.windows(2) {
let dd = seg_dist(pos, seg[0], seg[1]) - bonus;
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }));
}
}
}
}
if let Some((_, h)) = bestc {
hits.push(h);
@@ -847,6 +857,16 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
}
Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge),
// A 3D sketch curve, in whichever sketch has it.
Sel::SketchCurve { id } if st.sketches.iter().any(|ss| ss.sketch.wire_index(id).is_some()) => {
for ss in st.sketches.iter() {
if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) {
for q in w.pts.windows(2) {
sc.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true);
}
}
}
}
Sel::SketchCurve { id } => {
if let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
&& let Some(ci) = ss.sketch.curve_index(id)