From cb01314d35da02ed7b37e2b836f55ea98ddbe98c Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 23:54:18 +0000 Subject: [PATCH] 3D sketch curves (wires): Include 3D Geometry keeps out-of-plane edges and curves 3D; Project To Surface (curves onto a face along the sketch normal), Intersection Curve (bodies/faces), Isoparametric Curve (u/v or a direction), Spun Profile (a body's outline about an axis) and Fit Curves to Mesh Section; all linked and re-resolved; wires drawn, inspected and deletable Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/lib.rs | 1 + crates/doc/src/project.rs | 68 ++- crates/doc/src/project3d.rs | 409 ++++++++++++++++++ crates/engine/src/cmd/inspect.rs | 1 + crates/engine/src/cmd/sketch.rs | 2 + crates/engine/src/cmd/sketch_project.rs | 127 +++++- crates/engine/src/cmd/sketch_project_tests.rs | 103 +++++ crates/engine/src/tests.rs | 1 + crates/engine/src/view.rs | 4 + crates/sketch/src/lib.rs | 2 + crates/sketch/src/link.rs | 23 +- crates/sketch/src/model.rs | 5 + crates/sketch/src/wire.rs | 86 ++++ docs/parity.md | 16 +- 14 files changed, 835 insertions(+), 13 deletions(-) create mode 100644 crates/doc/src/project3d.rs create mode 100644 crates/sketch/src/wire.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 02d2188a..88e415f5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ mod eval; pub mod expr; mod params; pub mod project; +mod project3d; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use document::*; diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index d9e88332..7e342096 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -24,6 +24,8 @@ const MAX_CHAIN: usize = 4000; #[derive(Clone, Debug)] pub struct Resolved { pub geom: Vec, + /// 3D curves (world coordinates). + pub wires: Vec>, pub source: LinkSource, /// The reference was not exactly where it was and was re-found on the nearest geometry. pub moved: bool, @@ -34,6 +36,12 @@ pub fn resolve(doc: &Document, vals: &BTreeMap, st: &ModelState, let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists")); let mut moved = false; let mut source = src.clone(); + let mut wires: Vec> = Vec::new(); + // Included curves that leave the sketch plane stay 3D. + let off_plane = |pts: &[Vec3]| { + let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len())); + pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7) + }; let geom = match src { LinkSource::Edge { body, at } => { let (bi, mesh) = body_mesh(st, body, *at)?; @@ -48,6 +56,10 @@ pub fn resolve(doc: &Document, vals: &BTreeMap, st: &ModelState, let pts = mesh.edges.get(e).cloned().unwrap_or_default(); match kind { LinkKind::Intersect => section_of_polyline(plane, &pts), + LinkKind::Include if off_plane(&pts) => { + wires.push(pts); + Vec::new() + } _ => fit_world(plane, &pts), } } @@ -112,9 +124,58 @@ pub fn resolve(doc: &Document, vals: &BTreeMap, st: &ModelState, let pts: Vec = ss.sketch.segs(ci).iter().flat_map(|s| s.polyline(1e-3)).map(|p| ss.plane.to_world(p)).collect(); section_of_polyline(plane, &pts) } + LinkKind::Include => { + let pts: Vec = ss.sketch.polyline(ci).iter().map(|p| ss.plane.to_world(*p)).collect(); + if off_plane(&pts) { + wires.push(pts); + Vec::new() + } else { + project_sketch_curve(&ss.sketch, &ss.plane, ci, plane) + } + } _ => project_sketch_curve(&ss.sketch, &ss.plane, ci, plane), } } + LinkSource::OnSurface { sketch, curve, body, at } => { + let ss = st.sketch(*sketch).ok_or_else(|| lost(&format!("sketch {sketch}")))?; + let ci = ss.sketch.curve_index(curve).ok_or_else(|| lost(&format!("sketch curve {curve}")))?; + let pts: Vec = ss.sketch.polyline(ci).iter().map(|p| ss.plane.to_world(*p)).collect(); + let (bi, mesh) = body_mesh(st, body, *at)?; + let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?; + let size = mesh.bounds().diagonal().max(1e-9); + if d > size * 0.25 { + return Err(lost("the face")); + } + let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone()); + source = LinkSource::OnSurface { sketch: *sketch, curve: curve.clone(), body: name, at: q }; + wires = crate::project3d::onto_face(&mesh, f, &pts, plane.normal()); + Vec::new() + } + LinkSource::Intersection { a, b } => { + let (ma, fa) = crate::project3d::mesh_of(st, a).ok_or_else(|| lost(&a.describe()))?; + let (mb, fb) = crate::project3d::mesh_of(st, b).ok_or_else(|| lost(&b.describe()))?; + wires = crate::project3d::mesh_intersection(&ma, fa, &mb, fb); + Vec::new() + } + LinkSource::Iso { body, at, dir } => { + let (bi, mesh) = body_mesh(st, body, *at)?; + let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?; + let size = mesh.bounds().diagonal().max(1e-9); + if d > size * 0.25 { + return Err(lost("the face")); + } + let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone()); + source = LinkSource::Iso { body: name, at: q, dir: dir.clone() }; + wires = crate::project3d::iso_curve(&mesh, f, q, dir); + Vec::new() + } + LinkSource::Spun { body, origin, dir } => { + let b = st.body(body).ok_or_else(|| lost(&format!("body {body}")))?; + let pts = crate::project3d::spun_outline(&b.mesh(), *origin, *dir, plane); + let mut g = Vec::new(); + fit_chain(&pts, Some(b.mesh().bounds().diagonal().max(1e-9) * 2e-3), &mut g); + g + } LinkSource::SketchPoint { sketch, point } => { let ss = st.sketch(*sketch).ok_or_else(|| lost(&format!("sketch {sketch}")))?; let pi = ss.sketch.resolve_point(point).ok_or_else(|| lost(&format!("sketch point {point}")))?; @@ -144,10 +205,10 @@ pub fn resolve(doc: &Document, vals: &BTreeMap, st: &ModelState, plane_trace(plane, &other).into_iter().collect() } }; - if geom.is_empty() { + if geom.is_empty() && wires.is_empty() { return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe()))); } - Ok(Resolved { geom: merge_split_circles(geom), source, moved }) + Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved }) } /// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt @@ -159,6 +220,7 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap, st: &ModelS match resolve(doc, vals, st, plane, l.kind, &l.source) { Ok(r) => { sk.set_link_source(&l.id, r.source); + let _ = sk.set_link_wires(&l.id, &r.wires); match sk.update_link(&l.id, &r.geom) { Ok(true) => warn.push(format!("projected {} changed shape and was rebuilt", l.source.describe())), Ok(false) if r.moved => warn.push(format!("projected {} was re-found on the nearest geometry", l.source.describe())), @@ -382,7 +444,7 @@ fn silhouette(plane: &Plane, m: &Mesh) -> Vec { } /// Section of a mesh (or one of its faces) with the sketch plane. -fn section(plane: &Plane, m: &Mesh, face: Option) -> Vec { +pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option) -> Vec { let size = m.bounds().diagonal().max(1e-9); let q = size * 1e-7; let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64); diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs new file mode 100644 index 00000000..e09bdff0 --- /dev/null +++ b/crates/doc/src/project3d.rs @@ -0,0 +1,409 @@ +//! 3D sketch curves from the model: curves projected onto faces, intersection curves of bodies +//! and faces (on their display meshes), and spun profiles. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use solvecraft_geom::{Mesh, Plane, Vec2, Vec3}; +use solvecraft_sketch::LinkSource; + +use crate::eval::ModelState; + +/// Most triangle pairs examined by one intersection. +const MAX_PAIRS: usize = 20_000_000; + +/// The mesh (and face, for a face source) of a body or face reference. +pub(crate) fn mesh_of(st: &ModelState, src: &LinkSource) -> Option<(Arc, Option)> { + match src { + LinkSource::Body { body } => Some((st.body(body)?.mesh(), None)), + LinkSource::Face { body, at } => { + let b = st.body(body).or_else(|| st.bodies.iter().min_by(|x, y| dist_to(&x.mesh(), *at).total_cmp(&dist_to(&y.mesh(), *at))))?; + let m = b.mesh(); + let (f, _, _) = crate::project::nearest_face(&m, *at)?; + Some((m, Some(f))) + } + _ => None, + } +} + +fn dist_to(m: &Mesh, p: Vec3) -> f64 { + crate::project::nearest_face(m, p).map(|x| x.2).unwrap_or(f64::INFINITY) +} + +fn tris(m: &Mesh, face: Option) -> Vec<[Vec3; 3]> { + m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| face.is_none_or(|x| x == **f)).filter_map(|(t, _)| m.tri(t)).collect() +} + +/// Ray–triangle hit parameter (Möller–Trumbore), any sign. +fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option { + let (e1, e2) = (t[1] - t[0], t[2] - t[0]); + let p = d.cross(e2); + let det = e1.dot(p); + if det.abs() < 1e-14 { + return None; + } + let inv = 1.0 / det; + let s = o - t[0]; + let u = s.dot(p) * inv; + if !(-1e-9..=1.0 + 1e-9).contains(&u) { + return None; + } + let q = s.cross(e1); + let v = d.dot(q) * inv; + if v < -1e-9 || u + v > 1.0 + 1e-9 { + return None; + } + Some(e2.dot(q) * inv) +} + +/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that +/// miss the face split the result. +pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec> { + let Some(d) = dir.normalized() else { return Vec::new() }; + let ts = tris(m, Some(f)); + let size = m.bounds().diagonal().max(1e-9); + // Resample so the projected curve follows the surface. + let step = size / 200.0; + let mut dense: Vec = Vec::new(); + for w in pts.windows(2) { + let (a, b) = (w[0], w[1]); + let n = ((a.dist(b) / step).ceil() as usize).clamp(1, 2000); + for i in 0..n { + dense.push(a.lerp(b, i as f64 / n as f64)); + } + if dense.len() > 200_000 { + break; + } + } + if let Some(l) = pts.last() { + dense.push(*l); + } + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for p in dense { + let hit = ts.iter().filter_map(|t| ray_hit(p, d, t)).min_by(|a, b| a.abs().total_cmp(&b.abs())); + match hit { + Some(t) => cur.push(p + d * t), + None => { + if cur.len() >= 2 { + out.push(std::mem::take(&mut cur)); + } else { + cur.clear(); + } + } + } + } + if cur.len() >= 2 { + out.push(cur); + } + out +} + +/// Segment where two triangles cross (None when they do not, or are coplanar). +fn tri_tri(a: &[Vec3; 3], b: &[Vec3; 3]) -> Option<(Vec3, Vec3)> { + let nb = (b[1] - b[0]).cross(b[2] - b[0]).normalized()?; + let na = (a[1] - a[0]).cross(a[2] - a[0]).normalized()?; + // Points where one triangle's edges pass through the other's plane. + let cut = |t: &[Vec3; 3], n: Vec3, o: Vec3| -> Option<(Vec3, Vec3)> { + let h: Vec = t.iter().map(|p| (*p - o).dot(n)).collect(); + if h.iter().all(|x| *x > 1e-12) || h.iter().all(|x| *x < -1e-12) { + return None; + } + let mut pts = Vec::new(); + for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] { + let (hi, hj) = (h[i], h[j]); + if (hi > 0.0) != (hj > 0.0) { + pts.push(t[i].lerp(t[j], hi / (hi - hj))); + } else if hi == 0.0 { + pts.push(t[i]); + } + } + match pts[..] { + [p, q, ..] => Some((p, q)), + _ => None, + } + }; + if na.cross(nb).len() < 1e-9 { + return None; + } + let (a0, a1) = cut(a, nb, b[0])?; + let (b0, b1) = cut(b, na, a[0])?; + // Overlap of the two segments on the common line. + let dir = na.cross(nb); + let pr = |p: Vec3| p.dot(dir); + let (sa0, sa1) = if pr(a0) <= pr(a1) { (a0, a1) } else { (a1, a0) }; + let (sb0, sb1) = if pr(b0) <= pr(b1) { (b0, b1) } else { (b1, b0) }; + let lo = if pr(sa0) >= pr(sb0) { sa0 } else { sb0 }; + let hi = if pr(sa1) <= pr(sb1) { sa1 } else { sb1 }; + (pr(hi) - pr(lo) > 1e-12).then_some((lo, hi)) +} + +/// Where two meshes (or faces of them) meet, as polylines. +pub(crate) fn mesh_intersection(ma: &Mesh, fa: Option, mb: &Mesh, fb: Option) -> Vec> { + let (ta, tb) = (tris(ma, fa), tris(mb, fb)); + let bbox = |t: &[Vec3; 3]| (t[0].min(t[1]).min(t[2]), t[0].max(t[1]).max(t[2])); + // Uniform grid over B. + let lo = tb.iter().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), |m, t| m.min(bbox(t).0)); + let hi = tb.iter().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |m, t| m.max(bbox(t).1)); + if !(lo.is_finite() && hi.is_finite()) { + return Vec::new(); + } + let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 48); + let ext = hi - lo; + let cell = |p: Vec3| -> [usize; 3] { + let f = |v: f64, l: f64, e: f64| if e > 1e-12 { (((v - l) / e) * n as f64).floor().clamp(0.0, (n - 1) as f64) as usize } else { 0 }; + [f(p.x, lo.x, ext.x), f(p.y, lo.y, ext.y), f(p.z, lo.z, ext.z)] + }; + let mut grid: BTreeMap<[usize; 3], Vec> = BTreeMap::new(); + for (i, t) in tb.iter().enumerate() { + let (b0, b1) = bbox(t); + let (c0, c1) = (cell(b0), cell(b1)); + for x in c0[0]..=c1[0] { + for y in c0[1]..=c1[1] { + for z in c0[2]..=c1[2] { + grid.entry([x, y, z]).or_default().push(i); + } + } + } + } + let mut segs: Vec<(Vec3, Vec3)> = Vec::new(); + let mut pairs = 0usize; + for t in &ta { + let (b0, b1) = bbox(t); + if b1.x < lo.x || b1.y < lo.y || b1.z < lo.z || b0.x > hi.x || b0.y > hi.y || b0.z > hi.z { + continue; + } + let (c0, c1) = (cell(b0), cell(b1)); + let mut cand: Vec = Vec::new(); + for x in c0[0]..=c1[0] { + for y in c0[1]..=c1[1] { + for z in c0[2]..=c1[2] { + if let Some(v) = grid.get(&[x, y, z]) { + cand.extend(v); + } + } + } + } + cand.sort_unstable(); + cand.dedup(); + for j in cand { + pairs += 1; + if pairs > MAX_PAIRS { + break; + } + if let Some(u) = tb.get(j) + && let Some(sg) = tri_tri(t, u) + { + segs.push(sg); + } + } + } + chain3(&segs, ma.bounds().diagonal().max(mb.bounds().diagonal()).max(1e-9)) +} + +/// Chain 3D segments into polylines by shared end points. +fn chain3(segs: &[(Vec3, Vec3)], size: f64) -> Vec> { + let q = size * 1e-7; + let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64); + let mut adj: BTreeMap<(i64, i64, i64), (Vec3, Vec)> = BTreeMap::new(); + let mut uniq: Vec<((i64, i64, i64), (i64, i64, i64))> = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for (a, b) in segs { + let (ka, kb) = (key(*a), key(*b)); + if ka == kb { + continue; + } + let k = if ka <= kb { (ka, kb) } else { (kb, ka) }; + if !seen.insert(k) { + continue; + } + let i = uniq.len(); + uniq.push(k); + adj.entry(ka).or_insert_with(|| (*a, Vec::new())).1.push(i); + adj.entry(kb).or_insert_with(|| (*b, Vec::new())).1.push(i); + } + let mut used = vec![false; uniq.len()]; + let mut out = Vec::new(); + let starts: Vec<(i64, i64, i64)> = adj.iter().filter(|(_, v)| v.1.len() != 2).map(|(k, _)| *k).chain(adj.keys().copied()).collect(); + for s in starts { + while let Some(first) = adj.get(&s).and_then(|v| v.1.iter().copied().find(|e| !used.get(*e).copied().unwrap_or(true))) { + let mut chain = vec![adj.get(&s).map(|v| v.0).unwrap_or_default()]; + let mut cur = s; + let mut e = first; + loop { + if let Some(u) = used.get_mut(e) { + *u = true; + } + let Some(&(ka, kb)) = uniq.get(e) else { break }; + cur = if ka == cur { kb } else { ka }; + chain.push(adj.get(&cur).map(|v| v.0).unwrap_or_default()); + let Some(next) = adj + .get(&cur) + .and_then(|v| if v.1.len() == 2 { v.1.iter().copied().find(|x| !used.get(*x).copied().unwrap_or(true)) } else { None }) + else { + break; + }; + e = next; + } + if chain.len() >= 2 { + out.push(chain); + } + } + } + out +} + +/// Outline of a body spun about the axis `origin + t·dir`: the largest distance from the axis +/// at each station along it, laid into the sketch plane on the side of the axis the plane's +/// in-plane perpendicular points to. +pub(crate) fn spun_outline(m: &Mesh, origin: Vec3, dir: Vec3, plane: &Plane) -> Vec { + let Some(d) = dir.normalized() else { return Vec::new() }; + let Some(u) = plane.normal().cross(d).normalized() else { return Vec::new() }; + let ax = |p: Vec3| (p - origin).dot(d); + let rad = |p: Vec3| { + let v = p - origin; + (v - d * v.dot(d)).len() + }; + let (t0, t1) = m.positions.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| (a.min(ax(*p)), b.max(ax(*p)))); + if !(t0.is_finite() && t1 > t0) { + return Vec::new(); + } + let n = 240; + let mut out = Vec::new(); + for i in 0..=n { + let t = t0 + (t1 - t0) * i as f64 / n as f64; + let mut r = f64::NEG_INFINITY; + for tri in &m.triangles { + let Some(p) = m.tri(tri) else { continue }; + for (a, b) in [(p[0], p[1]), (p[1], p[2]), (p[2], p[0])] { + let (ha, hb) = (ax(a) - t, ax(b) - t); + if ha.abs() < 1e-12 { + r = r.max(rad(a)); + } + if (ha > 0.0) != (hb > 0.0) && (ha - hb).abs() > 1e-15 { + r = r.max(rad(a.lerp(b, ha / (ha - hb)))); + } + } + } + if r.is_finite() { + out.push(plane.to_local(origin + d * t + u * r)); + } + } + out +} + +/// An isoparametric curve of face `f` through `p`: the face cut by a plane through `p`. For +/// curved faces `u` runs around (the plane across the face's straight direction, e.g. a +/// cylinder's axis) and `v` along it; planar faces use the face plane's own axes. An explicit +/// direction `"x,y,z"` cuts along it. +pub(crate) fn iso_curve(m: &Mesh, f: u32, p: Vec3, dir: &str) -> Vec> { + let ns: Vec = m + .triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, x)| **x == f) + .filter_map(|(t, _)| m.tri(t)) + .filter_map(|t| (t[1] - t[0]).cross(t[2] - t[0]).normalized()) + .collect(); + let Some(n0) = crate::project::nearest_face(m, p).and_then(|_| ns.first().copied()) else { return Vec::new() }; + // Normal at p: of the nearest triangle of the face. + let np = m + .triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, x)| **x == f) + .filter_map(|(t, _)| m.tri(t)) + .min_by(|a, b| tri_dist(p, a).total_cmp(&tri_dist(p, b))) + .and_then(|t| (t[1] - t[0]).cross(t[2] - t[0]).normalized()) + .unwrap_or(n0); + // The face's straight direction: perpendicular to how its normals vary. + let straight = ns.iter().map(|n| np.cross(*n)).filter(|c| c.len() > 1e-3).fold(None::, |acc, c| match acc { + None => c.normalized(), + Some(a) => (a + if a.dot(c) < 0.0 { -c } else { c }).normalized(), + }); + let along = match dir.trim().to_ascii_lowercase().as_str() { + "u" | "v" => { + let s = straight.unwrap_or_else(|| Plane::from_normal(p, np).map(|pl| pl.x).unwrap_or(Vec3::X)); + if dir.trim().eq_ignore_ascii_case("v") { s } else { np.cross(s) } + } + other => { + let v: Vec = other.split([',', ' ']).filter_map(|x| x.trim().parse().ok()).collect(); + match v[..] { + [x, y, z] => Vec3::new(x, y, z), + _ => return Vec::new(), + } + } + }; + // Cutting plane through p containing the face normal and the curve direction. + let Some(cut_n) = np.cross(along).normalized() else { return Vec::new() }; + // Nudged off the point so the cut does not run exactly along mesh edges (a seam); the side + // that cuts more of the face wins. + let nudge = m.bounds().diagonal().max(1e-9) * 1e-8; + let cut = |off: f64| -> Vec> { + let Some(pl) = Plane::from_normal(p + cut_n * off, cut_n) else { return Vec::new() }; + let geom = crate::project::section(&pl, m, Some(f)); + let pieces: Vec> = + geom.iter().map(|g| link_polyline(g).into_iter().map(|q| pl.to_world(q)).collect()).filter(|w: &Vec| w.len() >= 2).collect(); + join_wires(pieces, m.bounds().diagonal().max(1e-9) * 1e-6) + }; + let len = |ws: &Vec>| ws.iter().flat_map(|w| w.windows(2).map(|x| x[0].dist(x[1]))).sum::(); + let (a, b) = (cut(nudge), cut(-nudge)); + if len(&a) >= len(&b) { a } else { b } +} + +/// Join polylines whose ends meet into longer ones. +fn join_wires(mut ws: Vec>, tol: f64) -> Vec> { + let mut out: Vec> = Vec::new(); + while let Some(mut cur) = ws.pop() { + let mut grew = true; + while grew { + grew = false; + for i in 0..ws.len() { + let (Some(cs), Some(ce)) = (cur.first().copied(), cur.last().copied()) else { break }; + let (Some(ws0), Some(we)) = (ws[i].first().copied(), ws[i].last().copied()) else { continue }; + let mut w = if ce.dist(ws0) < tol || cs.dist(we) < tol || ce.dist(we) < tol || cs.dist(ws0) < tol { ws.remove(i) } else { continue }; + if ce.dist(ws0) < tol { + cur.extend(w.drain(1..)); + } else if ce.dist(we) < tol { + w.reverse(); + cur.extend(w.drain(1..)); + } else if cs.dist(we) < tol { + w.extend(cur.drain(1..)); + cur = w; + } else { + w.reverse(); + w.extend(cur.drain(1..)); + cur = w; + } + grew = true; + break; + } + } + out.push(cur); + } + out +} + +fn tri_dist(p: Vec3, t: &[Vec3; 3]) -> f64 { + let c = (t[0] + t[1] + t[2]) / 3.0; + c.dist(p) +} + +/// Sample a fitted planar curve. +fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec { + use solvecraft_sketch::LinkGeom; + match *g { + LinkGeom::Line(a, b) => vec![a, b], + LinkGeom::Circle(c, r) => (0..=96).map(|i| c + Vec2::from_angle(i as f64 / 96.0 * std::f64::consts::TAU) * r).collect(), + LinkGeom::Arc { c, a, b } => { + let (s0, mut sw) = ((a - c).angle(), (b - c).angle() - (a - c).angle()); + while sw <= 1e-12 { + sw += std::f64::consts::TAU; + } + let r = c.dist(a); + (0..=48).map(|i| c + Vec2::from_angle(s0 + sw * i as f64 / 48.0) * r).collect() + } + LinkGeom::Conic { a, apex, b, rho } => (0..=32).map(|i| solvecraft_sketch::conic_point(a, apex, b, rho, i as f64 / 32.0)).collect(), + LinkGeom::Point(_) => Vec::new(), + } +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index ed7b8abd..a3449553 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -142,6 +142,7 @@ fn sketch_json(s: &Session, id: u64) -> Option { "constraints": constraints, "profiles": profiles, "links": sk.links, + "wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::>(), })) } diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 97abb94e..df490924 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -1245,6 +1245,8 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result { points.push(q); } else if sk.constraints.iter().any(|c| &c.id == id) { cons.push(id.clone()); + } else if sk.wire_index(id).is_some() { + sk.remove_wires(std::slice::from_ref(id)); } else if sk.link(id).is_some() { curves.extend(sk.link_curves(id)); points.extend(sk.link_points(id)); diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index b18fbe7b..30d84758 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -23,6 +23,31 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("include") .enabled(in_sketch) .params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"), + CommandSpec::new("ProjectToSurface", "Project To Surface", project_to_surface) + .at("SKETCH", "CREATE") + .icon("project_surface") + .enabled(in_sketch) + .params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"), + CommandSpec::new("IntersectionCurve", "Intersection Curve", intersection_curve) + .at("SKETCH", "CREATE") + .icon("intersection_curve") + .enabled(in_sketch) + .params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"), + CommandSpec::new("SpunProfileCmd", "Spun Profile", spun_profile) + .at("SKETCH", "CREATE") + .icon("spun") + .enabled(in_sketch) + .params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"), + CommandSpec::new("SketchIsoparametricCurve", "Isoparametric Curve", iso_curve) + .at("SKETCH", "CREATE") + .icon("iso_curve") + .enabled(in_sketch) + .params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"), + CommandSpec::new("FitCurvesToSectionCommand", "Fit Curves to Mesh Section", fit_section) + .at("SKETCH", "CREATE") + .icon("fit_section") + .enabled(in_sketch) + .params("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry"), CommandSpec::new("sketch.break_link", "Break Link", break_link) .enabled(in_sketch) .params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"), @@ -116,7 +141,7 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki let (vals, _) = doc.param_values(); let st = s.model.state_before(id); let r = resolve(doc, &vals, &st, &plane, kind, &src)?; - Ok(sk.add_link(kind, r.source, &r.geom)?) + Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?) } fn link_json(sk: &Sketch, link: &str) -> Value { @@ -162,6 +187,106 @@ fn include_cmd(s: &mut Session, p: &Value) -> Result { run_links(s, p, "Include3DGeometry", LinkKind::Include) } +/// Add one link from `src` to the active sketch and report it. +fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result { + let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?; + let mut doc = (*s.doc).clone(); + let mut sk = doc.sketch(id)?.clone(); + refresh(s, &doc, id, &mut sk); + let what = src.describe(); + let l = add_link(s, &doc, id, &mut sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?; + let mut out = link_json(&sk, &l); + let wires: Vec = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect(); + out["wires"] = json!(wires); + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok(out) +} + +fn project_to_surface(s: &mut Session, p: &Value) -> Result { + let cmd = "ProjectToSurface"; + let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the target face"))?; + let body = str_(p, "body").unwrap_or("").to_string(); + let list = p.get("curves").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`curves` must list {sketch, curve}"))?; + if list.is_empty() || list.len() > 500 { + return Err(bad(cmd, "`curves` must list 1…500 curves")); + } + let mut out = Vec::new(); + for v in list { + let LinkSource::SketchCurve { sketch, curve } = source(s, &v, cmd)? else { + return Err(bad(cmd, "each entry must be {sketch, curve}")); + }; + out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?); + } + Ok(json!({"links": out})) +} + +fn intersection_curve(s: &mut Session, p: &Value) -> Result { + let cmd = "IntersectionCurve"; + let side = |k: &str| -> Result { + let v = p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?; + match source(s, v, cmd)? { + x @ (LinkSource::Body { .. } | LinkSource::Face { .. }) => Ok(x), + _ => Err(bad(cmd, format!("`{k}` must be a body or a face"))), + } + }; + let (a, b) = (side("a")?, side("b")?); + one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) }) +} + +fn spun_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "SpunProfileCmd"; + let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string(); + let axis = str_(p, "axis").ok_or_else(|| bad(cmd, "`axis` must be a sketch line or X|Y|Z"))?; + let (origin, dir) = match axis.to_ascii_uppercase().as_str() { + "X" => (Vec3::ZERO, Vec3::X), + "Y" => (Vec3::ZERO, Vec3::Y), + "Z" => (Vec3::ZERO, Vec3::Z), + _ => { + let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?; + let st = s.model.state(); + let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?; + let ci = ss.sketch.curve_index(axis).ok_or_else(|| bad(cmd, format!("unknown curve `{axis}`")))?; + let Some(solvecraft_sketch::Shape::Line { a, b }) = ss.sketch.shape(ci) else { return Err(bad(cmd, "the axis must be a line")) }; + let (wa, wb) = (ss.plane.to_world(a), ss.plane.to_world(b)); + (wa, wb - wa) + } + }; + one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir }) +} + +fn iso_curve(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchIsoparametricCurve"; + let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the face"))?; + let body = str_(p, "body").unwrap_or("").to_string(); + let dir = match p.get("direction") { + None => "u".to_string(), + Some(Value::String(d)) if d.eq_ignore_ascii_case("u") || d.eq_ignore_ascii_case("v") => d.to_ascii_lowercase(), + Some(v) => { + let d = vec3(v).ok_or_else(|| bad(cmd, "`direction` must be u, v or [x,y,z]"))?; + format!("{},{},{}", d.x, d.y, d.z) + } + }; + one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir }) +} + +fn fit_section(s: &mut Session, p: &Value) -> Result { + let cmd = "FitCurvesToSectionCommand"; + let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string(); + if s.model.state().body(&body).is_none() { + return Err(bad(cmd, format!("no body `{body}`"))); + } + let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?; + let mut doc = (*s.doc).clone(); + let mut sk = doc.sketch(id)?.clone(); + let l = add_link(s, &doc, id, &mut sk, LinkKind::Intersect, LinkSource::Body { body }).map_err(|e| bad(cmd, e.to_string()))?; + let curves: Vec = sk.link_curves(&l).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect(); + sk.break_link(&l)?; + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok(json!({"curves": curves})) +} + fn break_link(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.break_link"; let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?; diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 0b1ca06f..a8c20ffd 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -195,3 +195,106 @@ fn drawing_on_a_model_vertex_projects_it() { let si = inspect(&mut s); assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}"); } + +#[test] +fn fit_curves_to_a_mesh_section() { + let mut s = Session::default(); + run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30})); + run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 10})); + let name = s.model.state().bodies[0].name.clone(); + let r = run(&mut s, "FitCurvesToSectionCommand", json!({"body": name})); + assert_eq!(r["curves"].as_array().unwrap().len(), 1, "{r}"); + let si = inspect(&mut s); + assert!(si["links"].as_array().unwrap().is_empty()); + let c = &si["curves"][0]; + assert_eq!(c["type"], "circle"); + assert!(c.get("link").is_none()); + assert!(s.execute("FitCurvesToSectionCommand", &json!({"body": "nope"})).is_err()); +} + +#[test] +fn three_d_sketch_curves() { + let mut s = Session::default(); + // Include keeps an out-of-plane edge 3D. + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + let r = run(&mut s, "Include3DGeometry", json!({"refs": [{"edge": [10, 10, 5]}]})); + let st = s.model.state(); + let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sk.wires.len(), 1, "{r}"); + assert!((sk.wires[0].pts.iter().map(|p| p.z).fold(0.0, f64::max) - 10.0).abs() < 1e-9); + run(&mut s, "SketchStop", json!({})); + + // Project a line onto a sphere along the sketch normal. + 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": [[-10, 0], [10, 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 st = s.model.state(); + let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch; + let w = &sk.wires[sk.wire_index(&wid).unwrap()]; + assert!(w.pts.len() > 20); + for p in &w.pts { + assert!((p.len() - 20.0).abs() < 0.1, "{p:?}"); + assert!(p.z > 0.0); + } + // Delete the wire. + run(&mut s, "sketch.delete", json!({"entities": [wid]})); + assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.wires.is_empty()); + run(&mut s, "SketchStop", json!({})); + + // Two crossing cylinders meet in 3D curves. + let mut s = Session::default(); + run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40})); + run(&mut s, "PrimitiveCylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"})); + let names: Vec = s.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + assert_eq!(names.len(), 2, "{names:?}"); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + let r = run(&mut s, "IntersectionCurve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}})); + assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}"); + let st = s.model.state(); + let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch; + for w in &sk.wires { + for p in &w.pts { + assert!(((p.x * p.x + p.y * p.y).sqrt() - 5.0).abs() < 0.1, "{p:?}"); + assert!(((p.y * p.y + p.z * p.z).sqrt() - 4.0).abs() < 0.1, "{p:?}"); + } + } + run(&mut s, "SketchStop", json!({})); + + // Spun profile of a cylinder about Z, on the XZ plane: a straight outline 6 from the axis. + let mut s = Session::default(); + run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20})); + let name = s.model.state().bodies[0].name.clone(); + run(&mut s, "SketchCreate", json!({"plane": "XZ"})); + let r = run(&mut s, "SpunProfileCmd", json!({"body": name, "axis": "Z"})); + let si = inspect(&mut s); + let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["curves"][0]).unwrap().clone(); + assert_eq!(c["type"], "line", "{c}"); + assert!((c["start_at"][0].as_f64().unwrap().abs() - 6.0).abs() < 1e-3, "{c}"); +} + +#[test] +fn isoparametric_curves_of_a_cylinder() { + let mut s = Session::default(); + run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20})); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + // u: around (a circle at z = 10); v: along (a line from z 0 to 20). + let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10]})); + let st = s.model.state(); + let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch; + let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()]; + assert!(w.pts.iter().all(|p| (p.z - 10.0).abs() < 1e-6 && ((p.x * p.x + p.y * p.y).sqrt() - 6.0).abs() < 1e-3), "{:?}", &w.pts[..3]); + let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10], "direction": "v"})); + let st = s.model.state(); + let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch; + let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()]; + let zs: Vec = w.pts.iter().map(|p| p.z).collect(); + assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}"); + assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3)); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4b0cc50a..dc5f14d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -287,6 +287,7 @@ fn hostile_params_never_panic() { "from", "datum", "tolerance", + "face", ]; let mut internal = Vec::new(); for spec in command_specs() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 542792d9..c1a2a673 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -87,6 +87,10 @@ 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)); } } + // 3D curves are in world coordinates already. + for w in &sk.wires { + out.push((w.pts.clone(), colors::SKETCH_PROJECTED, false)); + } out } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 32c9f142..4eb775f0 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -16,6 +16,7 @@ mod profiles; mod shape; mod solver; mod text; +mod wire; pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, end_curvature, spline_end_tangent, spline_point, spline_polyline}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; @@ -24,6 +25,7 @@ pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{SolveReport, SolveStatus, solve}; pub use text::{MAX_TEXT_CHARS, text_geometry}; +pub use wire::{MAX_WIRE_POINTS, Wire}; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index ceb59709..8eac0a12 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -55,6 +55,16 @@ pub enum LinkSource { /// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the /// sketch plane. Plane { name: String }, + /// A curve of a sketch projected onto a body face (through `at`) along the normal of the + /// sketch that holds the link: a 3D curve. + OnSurface { sketch: u64, curve: String, body: String, at: Vec3 }, + /// Where two bodies or faces (`Body` / `Face` sources) meet: 3D curves. + Intersection { a: Box, b: Box }, + /// An isoparametric curve of the face through `at`: `dir` is `u` (around a curved face), + /// `v` (along it) or an explicit direction `[x, y, z]` (as text). + Iso { body: String, at: Vec3, dir: String }, + /// The outline of a body spun about an axis (world line), laid into the sketch plane. + Spun { body: String, origin: Vec3, dir: Vec3 }, /// Text: baseline start `at` (sketch coordinates), capital height and angle (radians). Text { text: String, @@ -78,6 +88,10 @@ impl LinkSource { LinkSource::Origin => "origin".into(), LinkSource::Axis { name } => format!("{name} axis"), LinkSource::Plane { name } => format!("{name} plane"), + LinkSource::OnSurface { curve, .. } => format!("{curve} on a face"), + LinkSource::Intersection { a, b } => format!("intersection of {} and {}", a.describe(), b.describe()), + LinkSource::Spun { body, .. } => format!("spun profile of {body}"), + LinkSource::Iso { body, .. } => format!("isoparametric curve of {body}"), LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::()), } } @@ -513,6 +527,11 @@ impl Sketch { p.fixed = false; } } + for w in &mut self.wires { + if w.link.as_deref() == Some(id) { + w.link = None; + } + } self.links.retain(|l| l.id != id); Ok(()) } @@ -523,7 +542,9 @@ impl Sketch { return; } let used = |id: &str, sk: &Sketch| { - sk.curves.iter().any(|c| c.link.as_deref() == Some(id)) || sk.points.iter().any(|p| p.link.as_deref() == Some(id)) + sk.curves.iter().any(|c| c.link.as_deref() == Some(id)) + || sk.points.iter().any(|p| p.link.as_deref() == Some(id)) + || sk.wires.iter().any(|w| w.link.as_deref() == Some(id)) }; let keep: Vec = self.links.iter().map(|l| used(&l.id, self)).collect(); let mut k = keep.iter(); diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 2c4cdcb2..4f35a9c1 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -432,6 +432,9 @@ pub struct Sketch { /// Links to geometry outside the sketch (projections, intersections, includes). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub links: Vec, + /// 3D curves (world coordinates) carried by the sketch. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub wires: Vec, /// Next number per id prefix (`l` → l1, l2…). #[serde(default)] counters: std::collections::BTreeMap, @@ -450,6 +453,7 @@ impl Sketch { curves: Vec::new(), constraints: Vec::new(), links: Vec::new(), + wires: Vec::new(), counters: Default::default(), } } @@ -471,6 +475,7 @@ impl Sketch { || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id) || self.links.iter().any(|l| l.id == id) + || self.wires.iter().any(|w| w.id == id) } pub fn point_index(&self, id: &str) -> Option { diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs new file mode 100644 index 00000000..1298dbd4 --- /dev/null +++ b/crates/sketch/src/wire.rs @@ -0,0 +1,86 @@ +//! 3D sketch curves (wires): polylines in world coordinates that a sketch carries besides its +//! planar geometry — included 3D edges, curves projected onto surfaces, intersection curves. +//! They are reference geometry: drawn and usable as paths, not part of profiles or the solver. + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Vec3; + +use crate::link::{Link, LinkGeom, LinkKind, LinkSource}; +use crate::model::{Result, Sketch, SketchError}; + +/// Most points one wire may have. +pub const MAX_WIRE_POINTS: usize = 100_000; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Wire { + pub id: String, + /// World-space polyline. + pub pts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link: Option, +} + +fn clean(w: &[Vec3]) -> Option> { + let mut out: Vec = Vec::new(); + for p in w.iter().take(MAX_WIRE_POINTS) { + if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) { + return None; + } + if out.last().is_none_or(|l| l.dist(*p) > 1e-9) { + out.push(*p); + } + } + (out.len() >= 2).then_some(out) +} + +impl Sketch { + pub fn wire_index(&self, id: &str) -> Option { + self.wires.iter().position(|w| w.id == id) + } + + /// Wires owned by a link, in creation order. + pub fn link_wires(&self, id: &str) -> Vec { + self.wires.iter().enumerate().filter(|(_, w)| w.link.as_deref() == Some(id)).map(|(i, _)| i).collect() + } + + /// Add a link with planar geometry and/or 3D wires. + pub fn add_link_with_wires(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom], wires: &[Vec]) -> Result { + let ws: Vec> = wires.iter().filter_map(|w| clean(w)).collect(); + if ws.is_empty() { + return self.add_link(kind, source, geom); + } + let id = if geom.is_empty() { + let id = self.fresh("j"); + self.links.push(Link { id: id.clone(), kind, source, lost: false }); + id + } else { + self.add_link(kind, source, geom)? + }; + self.set_link_wires(&id, &ws)?; + Ok(id) + } + + /// Replace a link's wires (ids are kept in order; extra wires get new ids). + pub fn set_link_wires(&mut self, id: &str, wires: &[Vec]) -> Result<()> { + if self.link(id).is_none() { + return Err(SketchError::Unknown(id.into())); + } + let ws: Vec> = wires.iter().filter_map(|w| clean(w)).collect(); + let old: Vec = self.link_wires(id).iter().filter_map(|i| self.wires.get(*i).map(|w| w.id.clone())).collect(); + self.wires.retain(|w| w.link.as_deref() != Some(id)); + for (k, pts) in ws.into_iter().enumerate() { + let wid = match old.get(k) { + Some(o) => o.clone(), + None => self.fresh("w"), + }; + self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()) }); + } + Ok(()) + } + + /// Remove wires by id. + pub fn remove_wires(&mut self, ids: &[String]) { + self.wires.retain(|w| !ids.contains(&w.id)); + self.prune_links(); + } +} diff --git a/docs/parity.md b/docs/parity.md index c8470999..452da8a7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,9 +2,9 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 148 / 541 commands live (27%).** SOLID + SKETCH: 109 / 291 (37%). Registered commands: 151. +**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 153 / 541 commands live (28%).** SOLID + SKETCH: 114 / 291 (39%). Registered commands: 160. -Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 242 / 1175 (21%). +Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 247 / 1175 (21%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -17,7 +17,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SOLID | INSERT | 3 | 11 | 27% | | SOLID | SELECT | 0 | 15 | 0% | | SOLID | POSITION | 2 | 2 | 100% | -| SKETCH | CREATE | 35 | 41 | 85% | +| SKETCH | CREATE | 40 | 41 | 98% | | SKETCH | MODIFY | 12 | 12 | 100% | | SKETCH | CONSTRAINTS | 15 | 15 | 100% | | SKETCH | CONFIGURE | 0 | 3 | 0% | @@ -178,12 +178,17 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › CREATE › Conic Curve (`ConicCurveCmd`) - SKETCH › CREATE › Point (`DrawPoint`) - SKETCH › CREATE › Text (`MTextCmd`) +- SKETCH › CREATE › Fit Curves to Mesh Section (`FitCurvesToSectionCommand`) - SKETCH › CREATE › Mirror (`MirrorSketchCommand`) - SKETCH › CREATE › Circular Pattern (`CircularSketchPatternCommand`) - SKETCH › CREATE › Rectangular Pattern (`RectangularSketchPatternCommand`) - SKETCH › CREATE › Project (`ProjectNewCmd`) - SKETCH › CREATE › Intersect (`IntersectCmd`) +- SKETCH › CREATE › Spun Profile (`SpunProfileCmd`) - SKETCH › CREATE › Include 3D Geometry (`Include3DGeometry`) +- SKETCH › CREATE › Project To Surface (`ProjectToSurface`) +- SKETCH › CREATE › Intersection Curve (`IntersectionCurve`) +- SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`) - SKETCH › CREATE › Sketch Dimension (`SketchDimension`) - SKETCH › MODIFY › Fillet (`FilletSketchCmd`) - SKETCH › MODIFY › Equal Distance Chamfer (`ChamferSketchEqualDistance`) @@ -347,11 +352,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › SELECT › Select Component Priority (`SelectComponentPriorityCommand`) - SOLID › SELECT › Selection Filters (`SelectionFilterCommand`) - SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`) -- SKETCH › CREATE › Fit Curves to Mesh Section (`FitCurvesToSectionCommand`) -- SKETCH › CREATE › Spun Profile (`SpunProfileCmd`) -- SKETCH › CREATE › Project To Surface (`ProjectToSurface`) -- SKETCH › CREATE › Intersection Curve (`IntersectionCurve`) -- SKETCH › CREATE › Isoparametric Curve (`SketchIsoparametricCurve`) - SKETCH › CONFIGURE › Configure (`FusionStartDesignConfigModeCmd`) - SKETCH › CONFIGURE › Display Configuration Table (`FusionShowDesignConfigPanelCmd`) - SKETCH › CONFIGURE › Create Configuration Rules (`FusionConfigurationRulesCmd`)