diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index d58d3994..2ab5aaa3 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1041,6 +1041,10 @@ fn feature_tools(vals: &BTreeMap, 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, 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, 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, ®ion, &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, 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, r: f64, wall: &Option) -> Result { + 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>> { + 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::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 = 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| (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 = 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> { if ids.is_empty() || ids.len() > 1000 { return Err(DocError::Invalid("the path needs 1…1000 curves".into())); diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 8bccf8ef..d424547e 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -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); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 4e8cd00b..8a4cc563 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs new file mode 100644 index 00000000..f1b37ae8 --- /dev/null +++ b/crates/kernel/src/sweep_path.rs @@ -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> { + 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 = 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 { + 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> { + 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> { + 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 { + 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> { + 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 { + let pts: Vec = 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 { (*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 = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect(); + let mut wires: Vec> = 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::>>()?); + } + guard("path sweep", || { + let mut faces: Vec = 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 { + 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 = (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 = 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> = (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 = lp.segs.iter().map(|s| builder::vertex(p3(at(0, s.start())))).collect(); + let end: Vec = 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 = (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, ®ion, &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, ®ion, &[Vec3::ZERO]).is_err()); + assert!(sweep_path(&plane, ®ion, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err()); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fea52c72..645eb047 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2009,7 +2009,8 @@ fn source_features(s: &Session, bodies: &[String]) -> Vec { /// The sketch holding all these curve ids (the active sketch first, never `not`). fn curves_sketch(app: &SolveApp, ids: &[String], not: Option) -> Option { 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) diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index fb278313..fcf50fb6 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -483,6 +483,16 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { } } } + // 3D sketch curves (paths for sweeps and pipes). + for w in &ss.sketch.wires { + let pts: Vec = 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)