Sketch projection: Project (edges, face loops, body silhouettes, vertices, other sketches' curves and points, origin, axes, planes), Intersect (body/face/edge section with the sketch plane) and Include 3D Geometry as linked reference geometry: fixed, purple, re-resolved on every recompute (follows upstream edits, keeps ids when the shape is unchanged, flagged when the reference is lost), usable in profiles, constraints and dimensions; Break Link; sketching on a face auto-projects its edges (sketch.auto_project); drawing on a model vertex or edge projects it
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
eb22a149b1
commit
9301bc4210
17 files changed
+1842
-30
No files matched your search
@@ -963,10 +963,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let plane = doc.resolve_plane(vals, plane, 0)?;
|
||||
let mut sk = sketch.clone();
|
||||
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let report = solve(&mut sk);
|
||||
if !report.ok() {
|
||||
*warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
pub mod project;
|
||||
|
||||
pub use document::*;
|
||||
pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch};
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
//! Resolving sketch links against the model: projecting edges, faces, body silhouettes, other
|
||||
//! sketches and the origin onto a sketch plane, and intersecting bodies with it.
|
||||
//!
|
||||
//! Model geometry is found again on every recompute from the reference point stored in the
|
||||
//! link (like fillet edges). Edge polylines from the tessellation lie exactly on their curves,
|
||||
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
|
||||
//! a tilted circle, a spline edge) becomes a chain of lines.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solvecraft_geom::{Mesh, Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::{CurveKind, LinkGeom, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use crate::eval::ModelState;
|
||||
use crate::expr::Value;
|
||||
use crate::{DocError, Document, PlaneRef, Result};
|
||||
|
||||
/// Half length of the line an axis or plane projects to.
|
||||
const AXIS_HALF: f64 = 100.0;
|
||||
/// Points in one fitted chain at most (longer chains are thinned).
|
||||
const MAX_CHAIN: usize = 4000;
|
||||
|
||||
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Resolved {
|
||||
pub geom: Vec<LinkGeom>,
|
||||
pub source: LinkSource,
|
||||
/// The reference was not exactly where it was and was re-found on the nearest geometry.
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
/// Resolve a link source against the model state before the sketch.
|
||||
pub fn resolve(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, kind: LinkKind, src: &LinkSource) -> Result<Resolved> {
|
||||
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
|
||||
let mut moved = false;
|
||||
let mut source = src.clone();
|
||||
let geom = match src {
|
||||
LinkSource::Edge { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
let (e, q, d) = nearest_edge(&mesh, *at).ok_or_else(|| lost("the edge"))?;
|
||||
let size = mesh.bounds().diagonal().max(1e-9);
|
||||
if d > size * 0.25 {
|
||||
return Err(lost("the edge"));
|
||||
}
|
||||
moved = d > (size * 2e-3).max(1e-3);
|
||||
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
|
||||
source = LinkSource::Edge { body: name, at: q };
|
||||
let pts = mesh.edges.get(e).cloned().unwrap_or_default();
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
_ => fit_world(plane, &pts),
|
||||
}
|
||||
}
|
||||
LinkSource::Face { body, at } => {
|
||||
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"));
|
||||
}
|
||||
moved = d > (size * 2e-3).max(1e-3);
|
||||
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
|
||||
source = LinkSource::Face { body: name, at: q };
|
||||
match kind {
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let mut g = Vec::new();
|
||||
for e in mesh.face_edges(f) {
|
||||
if mesh.seams.get(e).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pts) = mesh.edges.get(e) {
|
||||
g.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkSource::Body { body } => {
|
||||
let b = st.body(body).ok_or_else(|| lost(&format!("body {body}")))?;
|
||||
let mesh = b.mesh();
|
||||
match kind {
|
||||
LinkKind::Intersect => section(plane, &mesh, None),
|
||||
_ => silhouette(plane, &mesh),
|
||||
}
|
||||
}
|
||||
LinkSource::Vertex { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
let size = mesh.bounds().diagonal().max(1e-9);
|
||||
let v = mesh
|
||||
.edges
|
||||
.iter()
|
||||
.flat_map(|e| [e.first().copied(), e.last().copied()])
|
||||
.flatten()
|
||||
.min_by(|a, b| a.dist(*at).total_cmp(&b.dist(*at)))
|
||||
.ok_or_else(|| lost("the vertex"))?;
|
||||
let d = v.dist(*at);
|
||||
if d > size * 0.25 {
|
||||
return Err(lost("the vertex"));
|
||||
}
|
||||
moved = d > (size * 2e-3).max(1e-3);
|
||||
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
|
||||
source = LinkSource::Vertex { body: name, at: v };
|
||||
vec![LinkGeom::Point(plane.to_local(v))]
|
||||
}
|
||||
LinkSource::SketchCurve { sketch, curve } => {
|
||||
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}")))?;
|
||||
match kind {
|
||||
LinkKind::Intersect => {
|
||||
let pts: Vec<Vec3> = 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)
|
||||
}
|
||||
_ => project_sketch_curve(&ss.sketch, &ss.plane, ci, plane),
|
||||
}
|
||||
}
|
||||
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}")))?;
|
||||
let p = ss.sketch.point(pi).unwrap_or_default();
|
||||
vec![LinkGeom::Point(plane.to_local(ss.plane.to_world(p)))]
|
||||
}
|
||||
LinkSource::Origin => vec![LinkGeom::Point(plane.to_local(Vec3::ZERO))],
|
||||
LinkSource::Axis { name } => {
|
||||
let d = match name.to_ascii_uppercase().as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
"Z" => Vec3::Z,
|
||||
_ => return Err(DocError::Unknown(format!("axis `{name}`"))),
|
||||
};
|
||||
let o = plane.to_local(Vec3::ZERO);
|
||||
let d2 = Vec2::new(d.dot(plane.x), d.dot(plane.y));
|
||||
match d2.normalized() {
|
||||
Some(u) if d2.len() > 1e-9 => vec![LinkGeom::Line(o - u * AXIS_HALF, o + u * AXIS_HALF)],
|
||||
_ => vec![LinkGeom::Point(o)],
|
||||
}
|
||||
}
|
||||
LinkSource::Plane { name } => {
|
||||
let pr =
|
||||
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
|
||||
let other = doc.resolve_plane(vals, &pr, 0).map_err(|_| lost(&format!("plane {name}")))?;
|
||||
plane_trace(plane, &other).into_iter().collect()
|
||||
}
|
||||
};
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
|
||||
}
|
||||
Ok(Resolved { geom, source, moved })
|
||||
}
|
||||
|
||||
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
|
||||
/// links; lost links keep their last geometry.
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
for l in &links {
|
||||
match resolve(doc, vals, st, plane, l.kind, &l.source) {
|
||||
Ok(r) => {
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
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())),
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected {}: {e}", l.source.describe()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected reference lost: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
warn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Model lookups
|
||||
|
||||
/// The body by name (or, when it is gone or unnamed, the one with geometry nearest `at`) and
|
||||
/// its display mesh.
|
||||
fn body_mesh(st: &ModelState, name: &str, at: Vec3) -> Result<(usize, std::sync::Arc<Mesh>)> {
|
||||
if let Some(i) = st.bodies.iter().position(|b| b.name == name)
|
||||
&& let Some(b) = st.bodies.get(i)
|
||||
{
|
||||
return Ok((i, b.mesh()));
|
||||
}
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, b) in st.bodies.iter().enumerate() {
|
||||
let m = b.mesh();
|
||||
if let Some((_, _, d)) = nearest_face(&m, at)
|
||||
&& best.is_none_or(|(_, bd)| d < bd)
|
||||
{
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
let (i, _) = best.ok_or_else(|| DocError::Invalid(format!("body {name} no longer exists")))?;
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid(format!("body {name} no longer exists")))?;
|
||||
Ok((i, b.mesh()))
|
||||
}
|
||||
|
||||
fn closest_on_segment(p: Vec3, a: Vec3, b: Vec3) -> Vec3 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.len2();
|
||||
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
a + ab * t
|
||||
}
|
||||
|
||||
/// Nearest non-seam edge: (index, closest point, distance).
|
||||
pub(crate) fn nearest_edge(m: &Mesh, p: Vec3) -> Option<(usize, Vec3, f64)> {
|
||||
let mut best: Option<(usize, Vec3, f64)> = None;
|
||||
for (i, e) in m.edges.iter().enumerate() {
|
||||
if m.seams.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
for w in e.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let q = closest_on_segment(p, *a, *b);
|
||||
let d = q.dist(p);
|
||||
if best.is_none_or(|(_, _, bd)| d < bd) {
|
||||
best = Some((i, q, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return closest_on_segment(p, a, b) };
|
||||
let q = p - nn * (p - a).dot(nn);
|
||||
let inside = (b - a).cross(q - a).dot(n) >= 0.0 && (c - b).cross(q - b).dot(n) >= 0.0 && (a - c).cross(q - c).dot(n) >= 0.0;
|
||||
if inside {
|
||||
return q;
|
||||
}
|
||||
[closest_on_segment(p, a, b), closest_on_segment(p, b, c), closest_on_segment(p, c, a)]
|
||||
.into_iter()
|
||||
.min_by(|x, y| x.dist(p).total_cmp(&y.dist(p)))
|
||||
.unwrap_or(a)
|
||||
}
|
||||
|
||||
/// Nearest face: (B-rep face index, closest point, distance).
|
||||
pub(crate) fn nearest_face(m: &Mesh, p: Vec3) -> Option<(u32, Vec3, f64)> {
|
||||
let mut best: Option<(u32, Vec3, f64)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let q = closest_on_triangle(p, a, b, c);
|
||||
let d = q.dist(p);
|
||||
if best.is_none_or(|(_, _, bd)| d < bd) {
|
||||
best = Some((*f, q, d));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Projection
|
||||
|
||||
fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec<LinkGeom> {
|
||||
let map = |p: Vec2| to.to_local(from.to_world(p));
|
||||
let parallel = from.normal().cross(to.normal()).len() < 1e-9;
|
||||
let flip = from.normal().dot(to.normal()) < 0.0;
|
||||
match sk.curves.get(ci).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => match (sk.point(*a), sk.point(*b)) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (a, b) = (map(a), map(b));
|
||||
if a.dist(b) > 1e-9 { vec![LinkGeom::Line(a, b)] } else { vec![LinkGeom::Point(a)] }
|
||||
}
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(CurveKind::Circle { c, r }) if parallel => sk.point(*c).map(|c| vec![LinkGeom::Circle(map(c), *r)]).unwrap_or_default(),
|
||||
Some(CurveKind::Arc { c, a, b }) if parallel => match (sk.point(*c), sk.point(*a), sk.point(*b)) {
|
||||
(Some(c), Some(a), Some(b)) => {
|
||||
let (c, a, b) = (map(c), map(a), map(b));
|
||||
vec![if flip { LinkGeom::Arc { c, a: b, b: a } } else { LinkGeom::Arc { c, a, b } }]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(_) => {
|
||||
let pts: Vec<Vec3> = sk.segs(ci).iter().flat_map(|s| s.polyline(1e-3)).map(|p| from.to_world(p)).collect();
|
||||
fit_world(to, &pts)
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The line where another plane crosses the sketch plane (None when parallel).
|
||||
fn plane_trace(sk: &Plane, other: &Plane) -> Option<LinkGeom> {
|
||||
let m = other.normal();
|
||||
let (a, b) = (sk.x.dot(m), sk.y.dot(m));
|
||||
let n2 = a * a + b * b;
|
||||
if n2 < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let c = (other.origin - sk.origin).dot(m);
|
||||
let p0 = Vec2::new(a, b) * (c / n2);
|
||||
let u = Vec2::new(-b, a).normalized()?;
|
||||
Some(LinkGeom::Line(p0 - u * AXIS_HALF, p0 + u * AXIS_HALF))
|
||||
}
|
||||
|
||||
/// Project a world polyline and fit lines and arcs to it.
|
||||
fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
let mut out = Vec::new();
|
||||
fit_chain(&p2, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a world polyline crosses the plane.
|
||||
fn section_of_polyline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
let mut out: Vec<LinkGeom> = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let (ha, hb) = (plane.height(*a), plane.height(*b));
|
||||
if (ha > 0.0) != (hb > 0.0) || ha == 0.0 {
|
||||
let t = if (ha - hb).abs() < 1e-15 { 0.0 } else { ha / (ha - hb) };
|
||||
let p = plane.to_local(a.lerp(*b, t));
|
||||
if !out.iter().any(|g| matches!(g, LinkGeom::Point(q) if q.dist(p) < 1e-7)) {
|
||||
out.push(LinkGeom::Point(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The outline of a body seen along the sketch normal: mesh edges between triangles facing the
|
||||
/// sketch and triangles facing away (or edge-on), projected and fitted.
|
||||
fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
|
||||
let n = plane.normal();
|
||||
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);
|
||||
// Undirected mesh edge → facing flags of the triangles using it.
|
||||
let mut edges: BTreeMap<((i64, i64, i64), (i64, i64, i64)), (Vec3, Vec3, Vec<bool>)> = BTreeMap::new();
|
||||
for t in &m.triangles {
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let fnrm = (b - a).cross(c - a);
|
||||
let Some(un) = fnrm.normalized() else { continue };
|
||||
let front = un.dot(n) > 1e-6;
|
||||
for (p, r) in [(a, b), (b, c), (c, a)] {
|
||||
let (kp, kr) = (key(p), key(r));
|
||||
let k = if kp <= kr { (kp, kr) } else { (kr, kp) };
|
||||
edges.entry(k).or_insert_with(|| (p, r, Vec::new())).2.push(front);
|
||||
}
|
||||
}
|
||||
let segs: Vec<(Vec2, Vec2)> = edges
|
||||
.values()
|
||||
.filter(|(_, _, f)| {
|
||||
let fronts = f.iter().filter(|x| **x).count();
|
||||
fronts > 0 && fronts < f.len() || (f.len() == 1 && fronts == 1)
|
||||
})
|
||||
.map(|(a, b, _)| (plane.to_local(*a), plane.to_local(*b)))
|
||||
.collect();
|
||||
fit_segments(&segs, size)
|
||||
}
|
||||
|
||||
/// Section of a mesh (or one of its faces) with the sketch plane.
|
||||
fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeom> {
|
||||
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);
|
||||
// Surface normal at each mesh vertex where the surface is smooth (None at creases).
|
||||
let mut normals: BTreeMap<(i64, i64, i64), Option<Vec3>> = BTreeMap::new();
|
||||
for (t, _) in m.triangles.iter().zip(&m.tri_face) {
|
||||
for k in t {
|
||||
let (Some(p), Some(n)) = (m.positions.get(*k as usize), m.normals.get(*k as usize)) else { continue };
|
||||
let Some(n) = n.normalized() else { continue };
|
||||
normals
|
||||
.entry(key(*p))
|
||||
.and_modify(|e| {
|
||||
if e.is_some_and(|o| o.dot(n) < 1.0 - 1e-6) {
|
||||
*e = None;
|
||||
}
|
||||
})
|
||||
.or_insert(Some(n));
|
||||
}
|
||||
}
|
||||
// Where the plane crosses a mesh edge: on a cubic through the end points whose end
|
||||
// tangents lie in the surface's tangent planes (so sections of curved faces land on the
|
||||
// surface, not on the chord), computed once per edge so neighbours agree.
|
||||
let exact: std::cell::RefCell<Vec<Vec2>> = std::cell::RefCell::new(Vec::new());
|
||||
let crossing = |a: Vec3, b: Vec3| -> Option<Vec3> {
|
||||
let (a, b) = if key(a) <= key(b) { (a, b) } else { (b, a) };
|
||||
let (ha, hb) = (plane.height(a), plane.height(b));
|
||||
if (ha > 0.0) == (hb > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let d = b - a;
|
||||
// Tangent length: the arc length of a circle through both ends with these normals.
|
||||
let (na, nb) = (normals.get(&key(a)).copied().flatten(), normals.get(&key(b)).copied().flatten());
|
||||
let l = match (na, nb) {
|
||||
(Some(x), Some(y)) => {
|
||||
let half = 0.5 * x.dot(y).clamp(-1.0, 1.0).acos();
|
||||
if half > 1e-9 { d.len() * half / half.sin() } else { d.len() }
|
||||
}
|
||||
_ => d.len(),
|
||||
};
|
||||
let tangent = |n: Option<Vec3>| -> Vec3 {
|
||||
match n {
|
||||
Some(n) => (d - n * d.dot(n)).normalized().map(|t| t * l).unwrap_or(d),
|
||||
None => d,
|
||||
}
|
||||
};
|
||||
// Along a ruling (same normal at both ends: planes, cylinder and cone generators) the
|
||||
// edge lies on the surface and the straight crossing is exact.
|
||||
if let (Some(x), Some(y)) = (na, nb)
|
||||
&& x.dot(y) > 1.0 - 1e-12
|
||||
{
|
||||
let p = a.lerp(b, ha / (ha - hb));
|
||||
exact.borrow_mut().push(plane.to_local(p));
|
||||
return Some(p);
|
||||
}
|
||||
let (ta, tb) = (tangent(na), tangent(nb));
|
||||
let h = |t: f64| {
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
a * (2.0 * t3 - 3.0 * t2 + 1.0) + ta * (t3 - 2.0 * t2 + t) + b * (-2.0 * t3 + 3.0 * t2) + tb * (t3 - t2)
|
||||
};
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..60 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if (plane.height(h(mid)) > 0.0) == (ha > 0.0) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
Some(h(0.5 * (lo + hi)))
|
||||
};
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if face.is_some_and(|x| x != *f) {
|
||||
continue;
|
||||
}
|
||||
let Some(tri) = m.tri(t) else { continue };
|
||||
let mut pts: Vec<Vec3> = Vec::new();
|
||||
for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
|
||||
let (Some(&pi), Some(&pj)) = (tri.get(i), tri.get(j)) else { continue };
|
||||
if let Some(x) = crossing(pi, pj) {
|
||||
pts.push(x);
|
||||
}
|
||||
}
|
||||
if let [a, b] = pts[..] {
|
||||
let (a, b) = (plane.to_local(a), plane.to_local(b));
|
||||
if a.dist(b) > size * 1e-9 {
|
||||
segs.push((a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let exact = exact.into_inner();
|
||||
let loose = size * 2e-3;
|
||||
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
|
||||
}
|
||||
|
||||
/// Re-fit a circle or arc through the exact section points near it, when there are enough.
|
||||
fn refine_round(g: LinkGeom, exact: &[Vec2], tol: f64) -> LinkGeom {
|
||||
let (c, r) = match g {
|
||||
LinkGeom::Circle(c, r) => (c, r),
|
||||
LinkGeom::Arc { c, a, .. } => (c, c.dist(a)),
|
||||
_ => return g,
|
||||
};
|
||||
let near: Vec<Vec2> = exact.iter().filter(|p| (p.dist(c) - r).abs() <= tol).copied().collect();
|
||||
// Arcs: only points within the arc's sweep.
|
||||
let near: Vec<Vec2> = match g {
|
||||
LinkGeom::Arc { c, a, b } => {
|
||||
let (s0, sw) = ((a - c).angle(), ((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU));
|
||||
near.into_iter().filter(|p| ((*p - c).angle() - s0).rem_euclid(std::f64::consts::TAU) <= sw + 1e-9).collect()
|
||||
}
|
||||
_ => near,
|
||||
};
|
||||
let Some((c2, r2)) = (near.len() >= 4).then(|| ls_circle(&near)).flatten() else { return g };
|
||||
if c2.dist(c) > tol || (r2 - r).abs() > tol || near.iter().any(|p| (p.dist(c2) - r2).abs() > tol * 1e-3) {
|
||||
return g;
|
||||
}
|
||||
let on = |q: Vec2| c2 + (q - c).normalized().unwrap_or(Vec2::X) * r2;
|
||||
match g {
|
||||
LinkGeom::Arc { a, b, .. } => LinkGeom::Arc { c: c2, a: on(a), b: on(b) },
|
||||
_ => LinkGeom::Circle(c2, r2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chain loose 2D segments into polylines (merging coincident ends) and fit each chain.
|
||||
fn fit_segments(segs: &[(Vec2, Vec2)], size: f64) -> Vec<LinkGeom> {
|
||||
// Mesh chords stray from the surface by up to the display tolerance (1/1000 of the size).
|
||||
let loose = Some(size * 2e-3);
|
||||
let q = size * 1e-7;
|
||||
let key = |p: Vec2| ((p.x / q).round() as i64, (p.y / q).round() as i64);
|
||||
let mut nodes: BTreeMap<(i64, i64), (Vec2, Vec<usize>)> = BTreeMap::new();
|
||||
let mut uniq: Vec<((i64, i64), (i64, i64))> = Vec::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 uniq.contains(&k) {
|
||||
continue;
|
||||
}
|
||||
let si = uniq.len();
|
||||
uniq.push(k);
|
||||
nodes.entry(ka).or_insert_with(|| (*a, Vec::new())).1.push(si);
|
||||
nodes.entry(kb).or_insert_with(|| (*b, Vec::new())).1.push(si);
|
||||
if uniq.len() > 200_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut used = vec![false; uniq.len()];
|
||||
let mut out = Vec::new();
|
||||
let walk = |start: (i64, i64), used: &mut Vec<bool>| -> Vec<Vec2> {
|
||||
let mut chain = vec![nodes.get(&start).map(|n| n.0).unwrap_or_default()];
|
||||
let mut cur = start;
|
||||
while let Some((_, adj)) = nodes.get(&cur) {
|
||||
let next = adj.iter().copied().find(|s| !used.get(*s).copied().unwrap_or(true));
|
||||
let Some(s) = next else { break };
|
||||
if let Some(u) = used.get_mut(s) {
|
||||
*u = true;
|
||||
}
|
||||
let Some(&(ka, kb)) = uniq.get(s) else { break };
|
||||
cur = if ka == cur { kb } else { ka };
|
||||
chain.push(nodes.get(&cur).map(|n| n.0).unwrap_or_default());
|
||||
// Stop at junctions so each chain is a simple path.
|
||||
if nodes.get(&cur).is_some_and(|n| n.1.len() != 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
chain
|
||||
};
|
||||
// Open chains start at ends and junctions; what is left are closed loops.
|
||||
let starts: Vec<(i64, i64)> = nodes.iter().filter(|(_, n)| n.1.len() != 2).map(|(k, _)| *k).collect();
|
||||
for s in starts {
|
||||
while nodes.get(&s).is_some_and(|n| n.1.iter().any(|e| !used.get(*e).copied().unwrap_or(true))) {
|
||||
let c = walk(s, &mut used);
|
||||
fit_chain(&c, loose, &mut out);
|
||||
}
|
||||
}
|
||||
let keys: Vec<(i64, i64)> = nodes.keys().copied().collect();
|
||||
for s in keys {
|
||||
while nodes.get(&s).is_some_and(|n| n.1.iter().any(|e| !used.get(*e).copied().unwrap_or(true))) {
|
||||
let c = walk(s, &mut used);
|
||||
fit_chain(&c, loose, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fitting
|
||||
|
||||
fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
|
||||
let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
||||
if d.abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let (a2, b2, c2) = (a.len2(), b.len2(), c.len2());
|
||||
let o = Vec2::new((a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d);
|
||||
let r = o.dist(a);
|
||||
(o.is_finite() && r.is_finite() && r < 1e7).then_some((o, r))
|
||||
}
|
||||
|
||||
/// Least-squares circle (algebraic fit, in coordinates centred on the points' mean).
|
||||
fn ls_circle(p: &[Vec2]) -> Option<(Vec2, f64)> {
|
||||
let n = p.len() as f64;
|
||||
if p.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let m = p.iter().fold(Vec2::ZERO, |a, q| a + *q) / n;
|
||||
// x² + y² + D x + E y + F = 0 → normal equations.
|
||||
let (mut a, mut r) = ([[0.0f64; 3]; 3], [0.0f64; 3]);
|
||||
for q in p {
|
||||
let (x, y) = (q.x - m.x, q.y - m.y);
|
||||
let row = [x, y, 1.0];
|
||||
let rhs = -(x * x + y * y);
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
a[i][j] += row[i] * row[j];
|
||||
}
|
||||
r[i] += row[i] * rhs;
|
||||
}
|
||||
}
|
||||
let det = |m: [[f64; 3]; 3]| {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
};
|
||||
let d0 = det(a);
|
||||
if d0.abs() < 1e-30 {
|
||||
return None;
|
||||
}
|
||||
let mut sol = [0.0; 3];
|
||||
for (k, s) in sol.iter_mut().enumerate() {
|
||||
let mut mk = a;
|
||||
for i in 0..3 {
|
||||
mk[i][k] = r[i];
|
||||
}
|
||||
*s = det(mk) / d0;
|
||||
}
|
||||
let c = Vec2::new(-sol[0] / 2.0, -sol[1] / 2.0);
|
||||
let rr = c.len2() - sol[2];
|
||||
let c = c + m;
|
||||
(rr > 0.0 && c.is_finite() && rr.sqrt() < 1e7).then(|| (c, rr.sqrt()))
|
||||
}
|
||||
|
||||
fn fits_line(p: &[Vec2], tol: f64) -> bool {
|
||||
let (Some(a), Some(b)) = (p.first(), p.last()) else { return false };
|
||||
let d = *b - *a;
|
||||
let l = d.len();
|
||||
if l <= tol {
|
||||
return false;
|
||||
}
|
||||
let u = d / l;
|
||||
let mut last = -tol;
|
||||
p.iter().all(|q| {
|
||||
let t = (*q - *a).dot(u);
|
||||
let ok = u.cross(*q - *a).abs() <= tol && t >= last - tol && t <= l + tol;
|
||||
last = t;
|
||||
ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Circle through the chain (centre, radius, signed sweep from the first to the last point).
|
||||
/// `exact`: the points lie on the curve (edge tessellation), else they are approximate
|
||||
/// (mesh sections and silhouettes) and the circle is a least-squares fit.
|
||||
fn fits_circle(p: &[Vec2], tol: f64, exact: bool) -> Option<(Vec2, f64, f64)> {
|
||||
let n = p.len();
|
||||
if n < 3 || (!exact && n < 5) {
|
||||
return None;
|
||||
}
|
||||
let closed = p.first()?.dist(*p.last()?) <= tol;
|
||||
let (c, r) = if exact {
|
||||
let (i1, i2) = if closed { (n / 3, 2 * n / 3) } else { (n / 2, n - 1) };
|
||||
circumcircle(*p.first()?, *p.get(i1)?, *p.get(i2)?)?
|
||||
} else {
|
||||
ls_circle(p)?
|
||||
};
|
||||
if r > 1e6 || p.iter().any(|q| (q.dist(c) - r).abs() > tol) {
|
||||
return None;
|
||||
}
|
||||
let mut sweep = 0.0;
|
||||
for w in p.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let mut d = (*b - c).angle() - (*a - c).angle();
|
||||
while d > std::f64::consts::PI {
|
||||
d -= std::f64::consts::TAU;
|
||||
}
|
||||
while d < -std::f64::consts::PI {
|
||||
d += std::f64::consts::TAU;
|
||||
}
|
||||
// Chords of a tessellated circle are short; points jumping around the circle (a
|
||||
// polygon inscribed in it) are not an arc.
|
||||
if d.abs() > 1.0 || (sweep != 0.0 && d * sweep < 0.0) {
|
||||
return None;
|
||||
}
|
||||
sweep += d;
|
||||
}
|
||||
Some((c, r, sweep))
|
||||
}
|
||||
|
||||
fn arc_geom(p: &[Vec2], c: Vec2, r: f64, sweep: f64, tol: f64) -> Option<LinkGeom> {
|
||||
let (a, b) = (*p.first()?, *p.last()?);
|
||||
if sweep.abs() >= std::f64::consts::TAU - 1e-3 || a.dist(b) <= tol {
|
||||
return Some(LinkGeom::Circle(c, r));
|
||||
}
|
||||
// Ends exactly on the circle.
|
||||
let on = |q: Vec2| c + (q - c).normalized().unwrap_or(Vec2::X) * r;
|
||||
let (a, b) = (on(a), on(b));
|
||||
Some(if sweep > 0.0 { LinkGeom::Arc { c, a, b } } else { LinkGeom::Arc { c, a: b, b: a } })
|
||||
}
|
||||
|
||||
/// Fit lines and circular arcs to a 2D polyline: the whole chain when it is one line or arc,
|
||||
/// else greedy maximal pieces. `loose`: the points are approximate (within `loose` mm), and
|
||||
/// sharp corners always split the chain.
|
||||
pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom>) {
|
||||
let mut p: Vec<Vec2> = Vec::with_capacity(raw.len());
|
||||
let scale = raw.iter().fold(0.0_f64, |m, q| m.max(q.x.abs()).max(q.y.abs())).max(1.0);
|
||||
let ext = match (
|
||||
raw.iter().map(|q| q.x).reduce(f64::min),
|
||||
raw.iter().map(|q| q.x).reduce(f64::max),
|
||||
raw.iter().map(|q| q.y).reduce(f64::min),
|
||||
raw.iter().map(|q| q.y).reduce(f64::max),
|
||||
) {
|
||||
(Some(x0), Some(x1), Some(y0), Some(y1)) => (x1 - x0).max(y1 - y0),
|
||||
_ => return,
|
||||
};
|
||||
let exact = loose.is_none();
|
||||
let tol = loose.unwrap_or(1e-6 * scale.max(ext)).max(1e-9);
|
||||
for q in raw {
|
||||
if !q.is_finite() {
|
||||
return;
|
||||
}
|
||||
if p.last().is_none_or(|l| l.dist(*q) > tol * 1e-3) {
|
||||
p.push(*q);
|
||||
}
|
||||
}
|
||||
if p.len() > MAX_CHAIN {
|
||||
let step = p.len().div_ceil(MAX_CHAIN);
|
||||
let last = p.last().copied();
|
||||
p = p.iter().step_by(step).copied().collect();
|
||||
if let Some(l) = last
|
||||
&& p.last() != Some(&l)
|
||||
{
|
||||
p.push(l);
|
||||
}
|
||||
}
|
||||
if p.len() < 2 || ext <= tol {
|
||||
return;
|
||||
}
|
||||
if !exact {
|
||||
// Split at corners (turns sharper than 30 degrees); a closed chain starts at one.
|
||||
let n = p.len();
|
||||
let closed = n > 3 && p.first().zip(p.last()).is_some_and(|(a, b)| a.dist(*b) <= tol);
|
||||
let turn = |i: usize| -> f64 {
|
||||
let prev = if i == 0 { if closed { n.saturating_sub(2) } else { return 0.0 } } else { i - 1 };
|
||||
let next = if i + 1 >= n { if closed { 1 } else { return 0.0 } } else { i + 1 };
|
||||
match (p.get(prev), p.get(i), p.get(next)) {
|
||||
(Some(a), Some(b), Some(c)) => match ((*b - *a).normalized(), (*c - *b).normalized()) {
|
||||
(Some(u), Some(v)) => u.cross(v).atan2(u.dot(v)).abs(),
|
||||
_ => 0.0,
|
||||
},
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
let corners: Vec<usize> = (0..n).filter(|i| turn(*i) > 30f64.to_radians()).collect();
|
||||
let cut = |a: usize, b: usize, out: &mut Vec<LinkGeom>| {
|
||||
if let Some(s) = p.get(a..=b) {
|
||||
fit_piece(s, tol, exact, out);
|
||||
}
|
||||
};
|
||||
if closed && let Some(&c0) = corners.first() {
|
||||
// Rotate so the loop starts and ends at a corner.
|
||||
let mut q: Vec<Vec2> = p.get(c0..n.saturating_sub(1)).unwrap_or_default().to_vec();
|
||||
q.extend(p.get(..=c0).unwrap_or_default());
|
||||
let m = q.len();
|
||||
let shift = |i: usize| (i + n - 1 - c0) % (n - 1);
|
||||
let mut cs: Vec<usize> = corners.iter().map(|c| shift(*c)).filter(|c| *c > 0 && *c < m - 1).collect();
|
||||
cs.sort_unstable();
|
||||
let mut start = 0;
|
||||
for c in cs.into_iter().chain([m - 1]) {
|
||||
if let Some(s) = q.get(start..=c) {
|
||||
fit_piece(s, tol, exact, out);
|
||||
}
|
||||
start = c;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut start = 0;
|
||||
for c in corners.into_iter().filter(|c| *c > 0 && *c < n - 1).chain([n - 1]) {
|
||||
cut(start, c, out);
|
||||
start = c;
|
||||
}
|
||||
return;
|
||||
}
|
||||
fit_piece(&p, tol, exact, out);
|
||||
}
|
||||
|
||||
fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
|
||||
if p.len() < 2 {
|
||||
return;
|
||||
}
|
||||
if fits_line(p, tol) {
|
||||
if let (Some(a), Some(b)) = (p.first(), p.last()) {
|
||||
out.push(LinkGeom::Line(*a, *b));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((c, r, sw)) = fits_circle(p, tol, exact)
|
||||
&& let Some(g) = arc_geom(p, c, r, sw, tol)
|
||||
{
|
||||
out.push(g);
|
||||
return;
|
||||
}
|
||||
// Greedy pieces.
|
||||
let mut i = 0;
|
||||
let n = p.len();
|
||||
let mut guard = 0;
|
||||
while i + 1 < n && guard < MAX_CHAIN * 2 {
|
||||
guard += 1;
|
||||
let mut jl = i + 1;
|
||||
while jl + 1 < n && p.get(i..=jl + 1).is_some_and(|s| fits_line(s, tol)) {
|
||||
jl += 1;
|
||||
}
|
||||
let mut jc = i + 1;
|
||||
let mut arc = None;
|
||||
while jc + 1 < n {
|
||||
match p.get(i..=jc + 1).and_then(|s| fits_circle(s, tol, exact)) {
|
||||
Some(f) => {
|
||||
arc = Some(f);
|
||||
jc += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if jc >= jl + 2
|
||||
&& jc - i >= 3
|
||||
&& let Some((c, r, sw)) = arc
|
||||
&& let Some(g) = p.get(i..=jc).and_then(|s| arc_geom(s, c, r, sw, tol))
|
||||
{
|
||||
out.push(g);
|
||||
i = jc;
|
||||
} else {
|
||||
if let (Some(a), Some(b)) = (p.get(i), p.get(jl)) {
|
||||
out.push(LinkGeom::Line(*a, *b));
|
||||
}
|
||||
i = jl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,18 +65,22 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
match c.kind {
|
||||
let mut v = match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
o.insert("link".into(), json!(l));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let points: Vec<Value> = sk
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
.constraints
|
||||
@@ -112,6 +116,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod features;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod sketch;
|
||||
mod sketch_project;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -104,6 +105,7 @@ pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
|
||||
@@ -192,6 +192,7 @@ fn edit<T>(
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
super::sketch_project::refresh(s, &doc, id, &mut sk);
|
||||
let out = f(&mut sk, &mut doc)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, &mut sk)?;
|
||||
@@ -356,7 +357,15 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = s.doc_mut().add_feature(FeatureKind::Sketch { plane, sketch: Sketch::new() }, name)?;
|
||||
s.active_sketch = Some(id);
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"sketch": id, "name": name}))
|
||||
// Sketching on a face projects the face's edges (unless turned off).
|
||||
let face = p.get("plane").and_then(|v| v.get("face")).and_then(vec3);
|
||||
let mut link = None;
|
||||
if let Some(at) = face
|
||||
&& bool_(p, "project_edges").unwrap_or(s.auto_project)
|
||||
{
|
||||
link = super::sketch_project::auto_project_face(s, id, at)?;
|
||||
}
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link}))
|
||||
}
|
||||
|
||||
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -387,6 +396,7 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "DrawPolyline";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
|
||||
if list.len() < 2 || list.len() > 10_000 {
|
||||
return Err(bad(cmd, "a line needs 2 or more points"));
|
||||
@@ -530,6 +540,7 @@ fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
|
||||
|
||||
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "CircleCenterRadius";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let r = radius_arg(p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
@@ -582,6 +593,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ArcThreePoint";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?);
|
||||
@@ -603,6 +615,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ArcCenterTwoPoint";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let sweep = num(p, "sweep");
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
@@ -1126,6 +1139,9 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
points.push(q);
|
||||
} else if sk.constraints.iter().any(|c| &c.id == id) {
|
||||
cons.push(id.clone());
|
||||
} else if sk.link(id).is_some() {
|
||||
curves.extend(sk.link_curves(id));
|
||||
points.extend(sk.link_points(id));
|
||||
} else {
|
||||
return Err(bad(cmd, format!("unknown sketch entity `{id}`")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Sketch projection: Project, Intersect and Include 3D Geometry bring model edges, faces,
|
||||
//! bodies, other sketches and the origin into the active sketch as linked reference geometry
|
||||
//! (fixed, drawn purple, updated when the upstream geometry changes). Break Link turns them into
|
||||
//! normal geometry.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::project::resolve;
|
||||
use solvecraft_doc::{Document, FeatureKind};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("ProjectNewCmd", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
|
||||
CommandSpec::new("IntersectCmd", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
|
||||
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
|
||||
),
|
||||
CommandSpec::new("Include3DGeometry", "Include 3D Geometry", include_cmd)
|
||||
.at("SKETCH", "CREATE")
|
||||
.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("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"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project)
|
||||
.noundo()
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
const REFS_DOC: &str = "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]";
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
};
|
||||
}
|
||||
let body = || str_(v, "body").unwrap_or("").to_string();
|
||||
if let Some(at) = v.get("edge").and_then(vec3) {
|
||||
return Ok(LinkSource::Edge { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("face").and_then(vec3) {
|
||||
return Ok(LinkSource::Face { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("vertex").and_then(vec3) {
|
||||
return Ok(LinkSource::Vertex { body: body(), at });
|
||||
}
|
||||
if let Some(sk) = v.get("sketch") {
|
||||
let key = match sk {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
|
||||
};
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = str_(v, "curve") {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = str_(v, "point") {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
|
||||
}
|
||||
if let Some(b) = str_(v, "body") {
|
||||
return Ok(LinkSource::Body { body: b.to_string() });
|
||||
}
|
||||
if let Some(a) = str_(v, "axis") {
|
||||
return Ok(LinkSource::Axis { name: a.to_ascii_uppercase() });
|
||||
}
|
||||
if let Some(p) = str_(v, "plane") {
|
||||
return Ok(LinkSource::Plane { name: p.to_string() });
|
||||
}
|
||||
if v.get("origin").is_some() {
|
||||
return Ok(LinkSource::Origin);
|
||||
}
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// The plane of a sketch feature.
|
||||
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
let (vals, _) = doc.param_values();
|
||||
match doc.feature(id).map(|f| &f.kind) {
|
||||
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?),
|
||||
_ => Err(EngineError::Other(format!("feature {id} is not a sketch"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk)
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
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)?)
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
|
||||
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
|
||||
json!({"link": link, "curves": curves, "points": points})
|
||||
}
|
||||
|
||||
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Value> {
|
||||
let refs: Vec<Value> = match p.get("refs").or_else(|| p.get("ref")) {
|
||||
Some(Value::Array(a)) => a.iter().take(1000).cloned().collect(),
|
||||
Some(v) => vec![v.clone()],
|
||||
None => return Err(bad(cmd, "`refs` must list what to project")),
|
||||
};
|
||||
if refs.is_empty() {
|
||||
return Err(bad(cmd, "`refs` is empty"));
|
||||
}
|
||||
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
refresh(s, &doc, id, &mut sk);
|
||||
let mut out = Vec::new();
|
||||
for src in sources {
|
||||
let what = src.describe();
|
||||
let l = add_link(s, &doc, id, &mut sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
out.push(link_json(&sk, &l));
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(json!({"links": out}))
|
||||
}
|
||||
|
||||
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_links(s, p, "ProjectNewCmd", LinkKind::Project)
|
||||
}
|
||||
|
||||
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_links(s, p, "IntersectCmd", LinkKind::Intersect)
|
||||
}
|
||||
|
||||
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_links(s, p, "Include3DGeometry", LinkKind::Include)
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.break_link";
|
||||
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let sk = doc.sketch_mut(id)?;
|
||||
let mut links: Vec<String> = Vec::new();
|
||||
if let Some(l) = str_(p, "link") {
|
||||
if sk.link(l).is_none() {
|
||||
return Err(bad(cmd, format!("no link `{l}`")));
|
||||
}
|
||||
links.push(l.to_string());
|
||||
}
|
||||
for e in crate::params::string_list(p, "entities") {
|
||||
let l = sk
|
||||
.curves
|
||||
.iter()
|
||||
.find(|c| c.id == e)
|
||||
.and_then(|c| c.link.clone())
|
||||
.or_else(|| sk.points.iter().find(|q| q.id == e).and_then(|q| q.link.clone()))
|
||||
.ok_or_else(|| bad(cmd, format!("`{e}` is not linked geometry")))?;
|
||||
if !links.contains(&l) {
|
||||
links.push(l);
|
||||
}
|
||||
}
|
||||
if links.is_empty() {
|
||||
return Err(bad(cmd, "give `link` or `entities`"));
|
||||
}
|
||||
for l in &links {
|
||||
sk.break_link(l)?;
|
||||
}
|
||||
*s.doc_mut() = doc;
|
||||
Ok(json!({"broken": links}))
|
||||
}
|
||||
|
||||
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
|
||||
Ok(json!({"auto_project": s.auto_project}))
|
||||
}
|
||||
|
||||
/// Point arguments that snap to model geometry while drawing, rewritten to sketch point ids:
|
||||
/// `{"vertex": [x,y,z]}` projects the vertex (a linked point); `{"on_edge": [x,y,z]}` projects
|
||||
/// the edge and makes a free point on it (coincident with the projection). Everything else is
|
||||
/// left as it is. The links are added to the active sketch in the session's document.
|
||||
pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let Some(id) = s.active_sketch else { return Ok(p.clone()) };
|
||||
let mut out = p.clone();
|
||||
let mut doc: Option<(Document, Sketch)> = None;
|
||||
let fix = |v: &mut Value, s: &Session, doc: &mut Option<(Document, Sketch)>| -> Result<()> {
|
||||
let (vertex, edge) = (v.get("vertex").and_then(vec3), v.get("on_edge").and_then(vec3));
|
||||
if vertex.is_none() && edge.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if doc.is_none() {
|
||||
let d = (*s.doc).clone();
|
||||
let mut sk = d.sketch(id)?.clone();
|
||||
refresh(s, &d, id, &mut sk);
|
||||
*doc = Some((d, sk));
|
||||
}
|
||||
let Some((d, sk)) = doc.as_mut() else { return Ok(()) };
|
||||
let body = str_(v, "body").unwrap_or("").to_string();
|
||||
let pid = if let Some(at) = vertex {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Vertex { body, at }).map_err(|e| bad(cmd, format!("vertex: {e}")))?;
|
||||
let pi = sk.link_points(&l).first().copied().ok_or_else(|| bad(cmd, "vertex projects to nothing"))?;
|
||||
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
|
||||
} else if let Some(at) = edge {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
|
||||
let plane = sketch_plane(d, id)?;
|
||||
let q = plane.to_local(at);
|
||||
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
|
||||
let pi = sk.add_point(q, None)?;
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: pi, c: ci }, None)?;
|
||||
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
*v = Value::String(pid);
|
||||
Ok(())
|
||||
};
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
for (_, v) in o.iter_mut() {
|
||||
match v {
|
||||
Value::Array(a) if a.len() <= 10_000 => {
|
||||
for x in a.iter_mut() {
|
||||
fix(x, s, &mut doc)?;
|
||||
}
|
||||
}
|
||||
Value::Object(_) => fix(v, s, &mut doc)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((mut d, sk)) = doc {
|
||||
*d.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = d;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, at: Vec3) -> Result<Option<String>> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let l = match add_link(s, &doc, id, &mut sk, LinkKind::Project, LinkSource::Face { body: String::new(), at }) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(Some(l))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,197 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
|
||||
fn box_part(s: &mut Session) {
|
||||
run(s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
|
||||
run(s, "SketchCreate", json!({"plane": "XY"}));
|
||||
let r = run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
let l = ids(&r["curves"]);
|
||||
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
let start = match sk.curves[0].kind {
|
||||
solvecraft_sketch::CurveKind::Line { a, .. } => sk.points[a].id.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
run(s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
|
||||
run(s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
|
||||
run(s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
|
||||
run(s, "SketchStop", json!({}));
|
||||
run(s, "Extrude", json!({"distance": 20}));
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session) -> Value {
|
||||
run(s, "sketch.inspect", json!({}))
|
||||
}
|
||||
|
||||
fn linked_curves(si: &Value) -> Vec<Value> {
|
||||
si["curves"].as_array().unwrap().iter().filter(|c| c.get("link").is_some()).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_on_face_projects_its_edges_and_follows_edits() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
assert!(r["projected"].is_string(), "{r}");
|
||||
let si = inspect(&mut s);
|
||||
let lc = linked_curves(&si);
|
||||
assert_eq!(lc.len(), 4, "{si}");
|
||||
assert!(lc.iter().all(|c| c["type"] == "line"));
|
||||
// The face loop is one closed profile of projected lines.
|
||||
assert_eq!(si["profiles"].as_array().unwrap().len(), 1, "{si}");
|
||||
assert!((si["profiles"][0]["area_mm2"].as_f64().unwrap() - 1200.0).abs() < 1e-6);
|
||||
// Projected geometry is fixed: the sketch has no degrees of freedom.
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
// A circle on the face, dimensioned to a projected edge.
|
||||
let c = run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 3}));
|
||||
let cid = ids(&c["curves"])[0].clone();
|
||||
let e = lc.iter().find(|c| c["start_at"][1].as_f64().unwrap().abs() < 1e-9 && c["end_at"][1].as_f64().unwrap().abs() < 1e-9).unwrap();
|
||||
run(&mut s, "SketchDimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
|
||||
let si = inspect(&mut s);
|
||||
let center = si["points"].as_array().unwrap().iter().find(|p| p["id"] == format!("{cid}.center")).unwrap();
|
||||
assert!((center["at"][1].as_f64().unwrap() - 8.0).abs() < 1e-7, "{center}");
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
// Widen the box: the projected loop follows.
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50 mm"}));
|
||||
let st = s.model.state();
|
||||
let ss = st.sketches.last().unwrap();
|
||||
assert_eq!(ss.profiles.len(), 2);
|
||||
let outer = ss.profiles.iter().map(|p| p.area).fold(0.0, f64::max);
|
||||
assert!((outer - (1500.0 - std::f64::consts::PI * 9.0)).abs() < 1e-6, "{outer}");
|
||||
let w = s.model.results.last().unwrap().warning.clone();
|
||||
assert!(w.is_none(), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_project_can_be_turned_off() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
|
||||
assert!(r["projected"].is_null());
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "sketch.auto_project", json!({"value": false}));
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
assert!(r["projected"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_edges_bodies_sketches_and_origin() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
run(&mut s, "PrimitiveCylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
// An edge on the top face projects to a line on XY.
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [20, 30, 20]}]}));
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
|
||||
assert_eq!(c["type"], "line");
|
||||
let ys: Vec<f64> = [c["start_at"][1].as_f64().unwrap(), c["end_at"][1].as_f64().unwrap()].to_vec();
|
||||
assert!(ys.iter().all(|y| (y - 30.0).abs() < 1e-9), "{c}");
|
||||
// The cylinder's silhouette seen from above is its circle, exactly.
|
||||
let name = s.model.state().bodies[1].name.clone();
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": name}]}));
|
||||
let cid = r["links"][0]["curves"][0].clone();
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == cid).unwrap().clone();
|
||||
assert_eq!(c["type"], "circle", "{c}");
|
||||
assert!((c["radius"].as_f64().unwrap() - 6.0).abs() < 1e-6, "{c}");
|
||||
assert!((c["center_at"][0].as_f64().unwrap() - 80.0).abs() < 1e-6, "{c}");
|
||||
// Origin, the Y axis and the YZ plane.
|
||||
run(&mut s, "ProjectNewCmd", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
|
||||
// The box silhouette from above is its 40 x 30 outline.
|
||||
let b0 = s.model.state().bodies[0].name.clone();
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": b0}]}));
|
||||
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
let first = s.active_sketch;
|
||||
assert!(first.is_none());
|
||||
let sk1 = s.doc.features.last().unwrap().id;
|
||||
// Another sketch projects a curve of the first one.
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
|
||||
let c2 = r["links"][0]["curves"][0].clone();
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == c2).unwrap().clone();
|
||||
assert_eq!(c["type"], "circle");
|
||||
// Bad references fail cleanly.
|
||||
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
|
||||
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_cuts_a_body_with_the_sketch_plane() {
|
||||
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": 12}));
|
||||
let name = s.model.state().bodies[0].name.clone();
|
||||
let r = run(&mut s, "IntersectCmd", json!({"refs": [{"body": name}]}));
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
|
||||
assert_eq!(c["type"], "circle", "{c}");
|
||||
assert!((c["radius"].as_f64().unwrap() - 7.0).abs() < 1e-6, "{c}");
|
||||
assert_eq!(si["profiles"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_link_makes_normal_geometry() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
let link = r["projected"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.break_link", json!({"link": link}));
|
||||
let si = inspect(&mut s);
|
||||
assert!(linked_curves(&si).is_empty());
|
||||
assert!(si["links"].as_array().unwrap().is_empty());
|
||||
// Now free: 4 lines with 4 shared corners, 8 degrees of freedom.
|
||||
assert_eq!(si["dof"], 8, "{si}");
|
||||
assert!(s.execute("sketch.break_link", &json!({"link": "j99"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_reference_keeps_geometry_with_a_warning() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let boxf = s.doc.features.last().unwrap().id;
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [5, 0, 10]}]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
|
||||
let st = s.model.state();
|
||||
let ss = st.sketches.last().unwrap();
|
||||
assert_eq!(ss.sketch.curves.len(), 1, "geometry kept");
|
||||
assert!(ss.sketch.links[0].lost);
|
||||
let w = s.model.results.last().unwrap().warning.clone().unwrap_or_default();
|
||||
assert!(w.contains("lost"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_on_a_model_vertex_projects_it() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "DrawPolyline", json!({"points": [{"vertex": [40, 30, 20]}, [60, 45]]}));
|
||||
let l = ids(&r["curves"])[0].clone();
|
||||
let si = inspect(&mut s);
|
||||
let start = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == l).unwrap()["start"].clone();
|
||||
let p = si["points"].as_array().unwrap().iter().find(|p| p["id"] == start).unwrap().clone();
|
||||
assert!(p["link"].is_string(), "{p}");
|
||||
assert_eq!(p["fixed"], true);
|
||||
assert!((p["at"][0].as_f64().unwrap() - 40.0).abs() < 1e-9);
|
||||
// Snapping onto an edge projects it and keeps the point on it.
|
||||
let r = run(&mut s, "DrawPolyline", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
|
||||
assert_eq!(ids(&r["curves"]).len(), 1);
|
||||
let si = inspect(&mut s);
|
||||
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
|
||||
}
|
||||
@@ -147,6 +147,8 @@ pub struct Session {
|
||||
pub log: Vec<String>,
|
||||
/// Increments on every document change (views use it to refresh).
|
||||
pub revision: u64,
|
||||
/// Project a face's edges into a sketch created on it (Fusion's default; `sketch.auto_project`).
|
||||
pub auto_project: bool,
|
||||
}
|
||||
|
||||
const MAX_UNDO: usize = 200;
|
||||
@@ -174,6 +176,7 @@ impl Session {
|
||||
path: None,
|
||||
log: Vec::new(),
|
||||
revision: 1,
|
||||
auto_project: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +267,7 @@ impl Session {
|
||||
saved: self.saved.clone(),
|
||||
log: Vec::new(),
|
||||
revision: self.revision,
|
||||
auto_project: self.auto_project,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,9 @@ fn hostile_params_never_panic() {
|
||||
"format",
|
||||
"ascii",
|
||||
"measure",
|
||||
"refs",
|
||||
"link",
|
||||
"project_edges",
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
|
||||
@@ -20,6 +20,10 @@ pub mod colors {
|
||||
pub const SKETCH_DONE: Rgb = Rgb(20, 20, 20);
|
||||
pub const SKETCH_CONSTRUCTION: Rgb = Rgb(230, 130, 40);
|
||||
pub const SKETCH_FIXED: Rgb = Rgb(20, 20, 20);
|
||||
/// Linked (projected) reference geometry.
|
||||
pub const SKETCH_PROJECTED: Rgb = Rgb(150, 60, 190);
|
||||
/// Linked geometry whose reference was lost.
|
||||
pub const SKETCH_LOST: Rgb = Rgb(200, 60, 90);
|
||||
pub const PROFILE: Rgb = Rgb(255, 196, 120);
|
||||
pub const GRID: Rgb = Rgb(196, 202, 212);
|
||||
pub const GRID_MAJOR: Rgb = Rgb(170, 178, 190);
|
||||
@@ -65,7 +69,9 @@ pub fn grid_step(half_height: f64) -> (f64, f64) {
|
||||
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
let col = if c.construction {
|
||||
let col = if c.link.is_some() {
|
||||
if sk.is_lost_curve(i) { colors::SKETCH_LOST } else { colors::SKETCH_PROJECTED }
|
||||
} else if c.construction {
|
||||
colors::SKETCH_CONSTRUCTION
|
||||
} else if !active {
|
||||
colors::SKETCH_DONE
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod linalg;
|
||||
mod link;
|
||||
mod model;
|
||||
mod profiles;
|
||||
mod solver;
|
||||
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError};
|
||||
pub use profiles::{Profile, find_profiles, merge_regions};
|
||||
pub use solver::{SolveReport, SolveStatus, solve};
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Linked (associative) reference geometry: curves and points projected into a sketch from
|
||||
//! model edges, faces, bodies, other sketches and the origin. The sketch only stores the link
|
||||
//! (what it refers to) and the geometry it produced last time; resolving a link against the
|
||||
//! model happens in the document crate, which hands the new geometry to [`Sketch::update_link`].
|
||||
//!
|
||||
//! Linked points are fixed and linked circles keep their radius, so the solver treats them as
|
||||
//! constants; they still bound profiles and take constraints and dimensions like any curve.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError};
|
||||
|
||||
/// Points of one link closer than this are merged into one point.
|
||||
const MERGE_TOL: f64 = 1e-7;
|
||||
/// Most entities a single link may produce.
|
||||
pub const MAX_LINK_ENTITIES: usize = 20_000;
|
||||
|
||||
/// How the geometry was brought in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LinkKind {
|
||||
/// Projected onto the sketch plane along its normal.
|
||||
#[default]
|
||||
Project,
|
||||
/// Included as is (curves in the sketch plane; others are flattened onto it).
|
||||
Include,
|
||||
/// Section curves of a body or face with the sketch plane.
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// What a link refers to. Model geometry is referenced by a point on it plus the body name and
|
||||
/// re-found on every recompute (like fillet edges), so it follows upstream edits.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LinkSource {
|
||||
/// A body edge through `at`.
|
||||
Edge { body: String, at: Vec3 },
|
||||
/// The boundary loops of the body face through `at` (Intersect: the face's section).
|
||||
Face { body: String, at: Vec3 },
|
||||
/// A whole body: its silhouette (Project) or its section (Intersect).
|
||||
Body { body: String },
|
||||
/// A body vertex at `at`.
|
||||
Vertex { body: String, at: Vec3 },
|
||||
/// A curve of another sketch.
|
||||
SketchCurve { sketch: u64, curve: String },
|
||||
/// A point of another sketch.
|
||||
SketchPoint { sketch: u64, point: String },
|
||||
/// The model origin.
|
||||
Origin,
|
||||
/// An origin axis (`X`, `Y`, `Z`).
|
||||
Axis { name: String },
|
||||
/// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the
|
||||
/// sketch plane.
|
||||
Plane { name: String },
|
||||
}
|
||||
|
||||
impl LinkSource {
|
||||
/// Short description for messages and the browser.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
LinkSource::Edge { body, .. } => format!("edge of {body}"),
|
||||
LinkSource::Face { body, .. } => format!("face of {body}"),
|
||||
LinkSource::Body { body } => body.clone(),
|
||||
LinkSource::Vertex { body, .. } => format!("vertex of {body}"),
|
||||
LinkSource::SketchCurve { curve, .. } => format!("sketch curve {curve}"),
|
||||
LinkSource::SketchPoint { point, .. } => format!("sketch point {point}"),
|
||||
LinkSource::Origin => "origin".into(),
|
||||
LinkSource::Axis { name } => format!("{name} axis"),
|
||||
LinkSource::Plane { name } => format!("{name} plane"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One link and its state.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Link {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub kind: LinkKind,
|
||||
pub source: LinkSource,
|
||||
/// The reference could not be found on the last recompute; the geometry is the last good one.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub lost: bool,
|
||||
}
|
||||
|
||||
/// Geometry a link produces, in sketch coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum LinkGeom {
|
||||
Point(Vec2),
|
||||
Line(Vec2, Vec2),
|
||||
Circle(Vec2, f64),
|
||||
/// Counter-clockwise arc around `c` from `a` to `b`.
|
||||
Arc {
|
||||
c: Vec2,
|
||||
a: Vec2,
|
||||
b: Vec2,
|
||||
},
|
||||
}
|
||||
|
||||
impl LinkGeom {
|
||||
fn finite(&self) -> bool {
|
||||
let ok = |p: &Vec2| p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
|
||||
match self {
|
||||
LinkGeom::Point(p) => ok(p),
|
||||
LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL,
|
||||
LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9,
|
||||
LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link geometry as merged points plus curves over them (indices into the point list).
|
||||
struct Layout {
|
||||
pts: Vec<Vec2>,
|
||||
/// Free-standing points (not owned by a curve).
|
||||
lone: Vec<usize>,
|
||||
curves: Vec<LCurve>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum LCurve {
|
||||
Line(usize, usize),
|
||||
Circle(usize, f64),
|
||||
Arc(usize, usize, usize),
|
||||
}
|
||||
|
||||
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
|
||||
if geom.len() > MAX_LINK_ENTITIES {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
let mut l = Layout { pts: Vec::new(), lone: Vec::new(), curves: Vec::new() };
|
||||
fn pt(pts: &mut Vec<Vec2>, p: Vec2) -> usize {
|
||||
if let Some(i) = pts.iter().position(|q| q.dist(p) <= MERGE_TOL) {
|
||||
return i;
|
||||
}
|
||||
pts.push(p);
|
||||
pts.len() - 1
|
||||
}
|
||||
for g in geom.iter().filter(|g| g.finite()) {
|
||||
match *g {
|
||||
LinkGeom::Point(p) => {
|
||||
let i = pt(&mut l.pts, p);
|
||||
if !l.lone.contains(&i) {
|
||||
l.lone.push(i);
|
||||
}
|
||||
}
|
||||
LinkGeom::Line(a, b) => {
|
||||
let (a, b) = (pt(&mut l.pts, a), pt(&mut l.pts, b));
|
||||
if a != b {
|
||||
l.curves.push(LCurve::Line(a, b));
|
||||
}
|
||||
}
|
||||
LinkGeom::Circle(c, r) => {
|
||||
let c = pt(&mut l.pts, c);
|
||||
l.curves.push(LCurve::Circle(c, r));
|
||||
}
|
||||
LinkGeom::Arc { c, a, b } => {
|
||||
let r = c.dist(a);
|
||||
let b = c + (b - c).normalized().unwrap_or(Vec2::X) * r;
|
||||
let (c, a, b) = (pt(&mut l.pts, c), pt(&mut l.pts, a), pt(&mut l.pts, b));
|
||||
if a != b && c != a {
|
||||
l.curves.push(LCurve::Arc(c, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lone points that a curve also uses are not lone.
|
||||
let used = |i: usize, cs: &[LCurve]| {
|
||||
cs.iter().any(|c| match *c {
|
||||
LCurve::Line(a, b) => a == i || b == i,
|
||||
LCurve::Circle(c, _) => c == i,
|
||||
LCurve::Arc(c, a, b) => c == i || a == i || b == i,
|
||||
})
|
||||
};
|
||||
let curves = l.curves.clone();
|
||||
l.lone.retain(|i| !used(*i, &curves));
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
fn same_shape(a: &LCurve, b: &LCurve) -> bool {
|
||||
matches!((a, b), (LCurve::Line(..), LCurve::Line(..)) | (LCurve::Circle(..), LCurve::Circle(..)) | (LCurve::Arc(..), LCurve::Arc(..)))
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
pub fn link(&self, id: &str) -> Option<&Link> {
|
||||
self.links.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
/// Curves owned by a link, in creation order.
|
||||
pub fn link_curves(&self, id: &str) -> Vec<usize> {
|
||||
self.curves.iter().enumerate().filter(|(_, c)| c.link.as_deref() == Some(id)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Points owned by a link, in creation order.
|
||||
pub fn link_points(&self, id: &str) -> Vec<usize> {
|
||||
self.points.iter().enumerate().filter(|(_, p)| p.link.as_deref() == Some(id)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Is this curve linked reference geometry?
|
||||
pub fn is_linked_curve(&self, ci: usize) -> bool {
|
||||
self.curves.get(ci).is_some_and(|c| c.link.is_some())
|
||||
}
|
||||
|
||||
/// Is the curve's link lost (its reference no longer found)?
|
||||
pub fn is_lost_curve(&self, ci: usize) -> bool {
|
||||
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost)
|
||||
}
|
||||
|
||||
/// Add linked geometry. Returns the link id; the new curve and point ids are
|
||||
/// [`Sketch::link_curves`] / [`Sketch::link_points`] of it.
|
||||
pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result<String> {
|
||||
let lay = layout(geom)?;
|
||||
if lay.curves.is_empty() && lay.lone.is_empty() {
|
||||
return Err(SketchError::Invalid("the reference projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
if self.points.len() + lay.pts.len() > crate::model::MAX_POINTS {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
let id = self.fresh("j");
|
||||
self.links.push(Link { id: id.clone(), kind, source, lost: false });
|
||||
self.build_link(&id, &lay)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn build_link(&mut self, id: &str, lay: &Layout) -> Result<()> {
|
||||
let mut idx: Vec<Option<usize>> = vec![None; lay.pts.len()];
|
||||
let mut point = |sk: &mut Sketch, li: usize| -> Result<usize> {
|
||||
if let Some(Some(i)) = idx.get(li) {
|
||||
return Ok(*i);
|
||||
}
|
||||
let p = lay.pts.get(li).copied().ok_or_else(|| SketchError::Invalid("link point".into()))?;
|
||||
let i = sk.add_point(p, None)?;
|
||||
if let Some(sp) = sk.points.get_mut(i) {
|
||||
sp.fixed = true;
|
||||
sp.link = Some(id.to_string());
|
||||
}
|
||||
if let Some(slot) = idx.get_mut(li) {
|
||||
*slot = Some(i);
|
||||
}
|
||||
Ok(i)
|
||||
};
|
||||
for c in &lay.curves {
|
||||
let (kind, prefix) = match *c {
|
||||
LCurve::Line(a, b) => (CurveKind::Line { a: point(self, a)?, b: point(self, b)? }, "l"),
|
||||
LCurve::Circle(c, r) => (CurveKind::Circle { c: point(self, c)?, r }, "c"),
|
||||
LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"),
|
||||
};
|
||||
let cid = self.fresh(prefix);
|
||||
self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()) });
|
||||
}
|
||||
for li in &lay.lone {
|
||||
point(self, *li)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a link's geometry with a newly resolved one. When the shape is the same (same
|
||||
/// curve kinds sharing the same points) the entities keep their ids and only move, so
|
||||
/// constraints and dimensions on them survive; otherwise the link is rebuilt. Returns
|
||||
/// `true` when it was rebuilt.
|
||||
pub fn update_link(&mut self, id: &str, geom: &[LinkGeom]) -> Result<bool> {
|
||||
if self.link(id).is_none() {
|
||||
return Err(SketchError::Unknown(id.into()));
|
||||
}
|
||||
let lay = layout(geom)?;
|
||||
let curves = self.link_curves(id);
|
||||
let points = self.link_points(id);
|
||||
// Current layout of the link, in the same terms.
|
||||
let local = |p: usize| points.iter().position(|q| *q == p);
|
||||
let cur: Option<Vec<LCurve>> = curves
|
||||
.iter()
|
||||
.map(|ci| match self.curves.get(*ci).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => Some(LCurve::Line(local(*a)?, local(*b)?)),
|
||||
Some(CurveKind::Circle { c, r }) => Some(LCurve::Circle(local(*c)?, *r)),
|
||||
Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)),
|
||||
None => None,
|
||||
})
|
||||
.collect();
|
||||
// The new layout numbers points in first-use order: curves first, then lone points,
|
||||
// exactly as `build_link` creates them.
|
||||
let mut order: Vec<usize> = Vec::new();
|
||||
let mut visit = |i: usize| {
|
||||
if !order.contains(&i) {
|
||||
order.push(i);
|
||||
}
|
||||
};
|
||||
for c in &lay.curves {
|
||||
match *c {
|
||||
LCurve::Line(a, b) => {
|
||||
visit(a);
|
||||
visit(b);
|
||||
}
|
||||
LCurve::Circle(c, _) => visit(c),
|
||||
LCurve::Arc(c, a, b) => {
|
||||
visit(c);
|
||||
visit(a);
|
||||
visit(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in &lay.lone {
|
||||
visit(*i);
|
||||
}
|
||||
let renum = |i: usize| order.iter().position(|q| *q == i).unwrap_or(usize::MAX);
|
||||
let new: Vec<LCurve> = lay
|
||||
.curves
|
||||
.iter()
|
||||
.map(|c| match *c {
|
||||
LCurve::Line(a, b) => LCurve::Line(renum(a), renum(b)),
|
||||
LCurve::Circle(c, r) => LCurve::Circle(renum(c), r),
|
||||
LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)),
|
||||
})
|
||||
.collect();
|
||||
let same = match &cur {
|
||||
Some(cur) => {
|
||||
cur.len() == new.len()
|
||||
&& order.len() == points.len()
|
||||
&& cur.iter().zip(&new).all(|(a, b)| {
|
||||
same_shape(a, b)
|
||||
&& match (a, b) {
|
||||
(LCurve::Line(a0, a1), LCurve::Line(b0, b1)) => a0 == b0 && a1 == b1,
|
||||
(LCurve::Circle(a0, _), LCurve::Circle(b0, _)) => a0 == b0,
|
||||
(LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
|
||||
l.lost = false;
|
||||
}
|
||||
if same {
|
||||
for (k, pi) in points.iter().enumerate() {
|
||||
let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
|
||||
if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
|
||||
sp.pos = p;
|
||||
}
|
||||
}
|
||||
for (ci, c) in curves.iter().zip(&new) {
|
||||
if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
|
||||
*slot = *r;
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
|
||||
self.drop_link_entities(id);
|
||||
self.build_link(id, &lay)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn drop_link_entities(&mut self, id: &str) {
|
||||
let curves = self.link_curves(id);
|
||||
let keep = self.links.clone();
|
||||
self.remove_curves(&curves);
|
||||
let pts = self.link_points(id);
|
||||
self.remove_points(&pts);
|
||||
// Removal prunes empty links; this one stays.
|
||||
self.links = keep;
|
||||
}
|
||||
|
||||
/// Mark a link as lost (its reference was not found); its geometry stays as it was.
|
||||
pub fn set_link_lost(&mut self, id: &str, lost: bool) {
|
||||
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
|
||||
l.lost = lost;
|
||||
}
|
||||
}
|
||||
|
||||
/// Store an updated reference (e.g. the point on an edge after an upstream edit).
|
||||
pub fn set_link_source(&mut self, id: &str, source: LinkSource) {
|
||||
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
|
||||
l.source = source;
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn linked geometry into normal sketch geometry: the link goes, the curves and points
|
||||
/// stay where they are and become free.
|
||||
pub fn break_link(&mut self, id: &str) -> Result<()> {
|
||||
if self.link(id).is_none() {
|
||||
return Err(SketchError::Unknown(id.into()));
|
||||
}
|
||||
for c in &mut self.curves {
|
||||
if c.link.as_deref() == Some(id) {
|
||||
c.link = None;
|
||||
}
|
||||
}
|
||||
for p in &mut self.points {
|
||||
if p.link.as_deref() == Some(id) {
|
||||
p.link = None;
|
||||
p.fixed = false;
|
||||
}
|
||||
}
|
||||
self.links.retain(|l| l.id != id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove links that no longer own any curve or point.
|
||||
pub(crate) fn prune_links(&mut self) {
|
||||
if self.links.is_empty() {
|
||||
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))
|
||||
};
|
||||
let keep: Vec<bool> = self.links.iter().map(|l| used(&l.id, self)).collect();
|
||||
let mut k = keep.iter();
|
||||
self.links.retain(|_| k.next().copied().unwrap_or(true));
|
||||
}
|
||||
}
|
||||
+28
-13
@@ -19,7 +19,7 @@ pub enum SketchError {
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, SketchError>;
|
||||
pub(crate) type Result<T> = std::result::Result<T, SketchError>;
|
||||
|
||||
/// A sketch point. `fixed` points are constants for the solver.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -28,6 +28,9 @@ pub struct SPoint {
|
||||
pub pos: Vec2,
|
||||
#[serde(default)]
|
||||
pub fixed: bool,
|
||||
/// Projected (linked) reference geometry: the id of the link that owns this point.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -53,6 +56,10 @@ pub struct Curve {
|
||||
/// as-drawn meaning.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub reversed: bool,
|
||||
/// Projected (linked) reference geometry: the id of the link that owns this curve. Its
|
||||
/// points are fixed and a linked circle keeps its radius.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
/// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or
|
||||
@@ -303,6 +310,9 @@ pub struct Sketch {
|
||||
pub points: Vec<SPoint>,
|
||||
pub curves: Vec<Curve>,
|
||||
pub constraints: Vec<Constraint>,
|
||||
/// Links to geometry outside the sketch (projections, intersections, includes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub links: Vec<crate::link::Link>,
|
||||
/// Next number per id prefix (`l` → l1, l2…).
|
||||
#[serde(default)]
|
||||
counters: std::collections::BTreeMap<String, u64>,
|
||||
@@ -317,28 +327,31 @@ impl Default for Sketch {
|
||||
impl Sketch {
|
||||
pub fn new() -> Self {
|
||||
Sketch {
|
||||
points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }],
|
||||
points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true, link: None }],
|
||||
curves: Vec::new(),
|
||||
constraints: Vec::new(),
|
||||
links: Vec::new(),
|
||||
counters: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new unused id with the given prefix (`l` → `l3`).
|
||||
pub fn fresh(&mut self, prefix: &str) -> String {
|
||||
let n = self.counters.entry(prefix.to_string()).or_insert(0);
|
||||
let mut n = self.counters.get(prefix).copied().unwrap_or(0);
|
||||
loop {
|
||||
*n += 1;
|
||||
n += 1;
|
||||
let id = format!("{prefix}{n}");
|
||||
let taken =
|
||||
self.points.iter().any(|p| p.id == id) || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id);
|
||||
if !taken {
|
||||
if !self.id_taken(&id) {
|
||||
self.counters.insert(prefix.to_string(), n);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn id_taken(&self, id: &str) -> bool {
|
||||
self.points.iter().any(|p| p.id == id) || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id)
|
||||
pub(crate) fn id_taken(&self, id: &str) -> bool {
|
||||
self.points.iter().any(|p| p.id == id)
|
||||
|| self.curves.iter().any(|c| c.id == id)
|
||||
|| self.constraints.iter().any(|c| c.id == id)
|
||||
|| self.links.iter().any(|l| l.id == id)
|
||||
}
|
||||
|
||||
pub fn point_index(&self, id: &str) -> Option<usize> {
|
||||
@@ -367,7 +380,7 @@ impl Sketch {
|
||||
Some(i) => i.to_string(),
|
||||
None => self.fresh("p"),
|
||||
};
|
||||
self.points.push(SPoint { id, pos: p, fixed: false });
|
||||
self.points.push(SPoint { id, pos: p, fixed: false, link: None });
|
||||
Ok(self.points.len() - 1)
|
||||
}
|
||||
|
||||
@@ -395,7 +408,7 @@ impl Sketch {
|
||||
return Err(SketchError::Invalid("line needs two distinct points".into()));
|
||||
}
|
||||
let id = self.curve_id(id, "l")?;
|
||||
self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false, reversed: false });
|
||||
self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false, reversed: false, link: None });
|
||||
Ok(self.curves.len() - 1)
|
||||
}
|
||||
|
||||
@@ -424,7 +437,7 @@ impl Sketch {
|
||||
Some(i) if i < self.points.len() => i,
|
||||
_ => self.own_point(&id, "center", center)?,
|
||||
};
|
||||
self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false, reversed: false });
|
||||
self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false, reversed: false, link: None });
|
||||
Ok(self.curves.len() - 1)
|
||||
}
|
||||
|
||||
@@ -450,7 +463,7 @@ impl Sketch {
|
||||
Some(i) if i < n && i != a => i,
|
||||
_ => self.own_point(&id, "end", p1)?,
|
||||
};
|
||||
self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false, reversed: false });
|
||||
self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false, reversed: false, link: None });
|
||||
Ok(self.curves.len() - 1)
|
||||
}
|
||||
|
||||
@@ -576,6 +589,7 @@ impl Sketch {
|
||||
orphans.sort_unstable();
|
||||
orphans.dedup();
|
||||
self.remove_points(&orphans);
|
||||
self.prune_links();
|
||||
}
|
||||
|
||||
fn point_used_by_curve(&self, p: usize) -> bool {
|
||||
@@ -656,6 +670,7 @@ impl Sketch {
|
||||
idx += 1;
|
||||
k
|
||||
});
|
||||
self.prune_links();
|
||||
}
|
||||
|
||||
/// Resolve a point reference: a point id (`origin`, `l1.end`, `p3`) or `<curve>.start|end|center`.
|
||||
|
||||
@@ -86,7 +86,7 @@ fn layout(sk: &Sketch) -> Layout {
|
||||
.curves
|
||||
.iter()
|
||||
.map(|c| match c.kind {
|
||||
CurveKind::Circle { .. } => {
|
||||
CurveKind::Circle { .. } if c.link.is_none() => {
|
||||
n += 1;
|
||||
Some(n - 1)
|
||||
}
|
||||
|
||||
@@ -301,3 +301,64 @@ fn merging_adjacent_regions() {
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 50.0).abs() < 1e-9 && (areas[1] - 200.0).abs() < 1e-9, "{areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_update_in_place_rebuild_and_break() {
|
||||
use crate::{LinkGeom, LinkKind, LinkSource};
|
||||
let mut sk = Sketch::new();
|
||||
let src = LinkSource::Origin;
|
||||
let sq = |s: f64| {
|
||||
vec![
|
||||
LinkGeom::Line(v(0.0, 0.0), v(s, 0.0)),
|
||||
LinkGeom::Line(v(s, 0.0), v(s, s)),
|
||||
LinkGeom::Line(v(s, s), v(0.0, s)),
|
||||
LinkGeom::Line(v(0.0, s), v(0.0, 0.0)),
|
||||
]
|
||||
};
|
||||
let j = sk.add_link(LinkKind::Project, src, &sq(10.0)).unwrap();
|
||||
assert_eq!(sk.link_curves(&j).len(), 4);
|
||||
// Corners are shared: 4 points, all fixed.
|
||||
let pts = sk.link_points(&j);
|
||||
assert_eq!(pts.len(), 4);
|
||||
assert!(pts.iter().all(|p| sk.points[*p].fixed));
|
||||
// A free line dimensioned to a projected corner keeps its constraint across an update.
|
||||
let l = sk.add_line(v(3.0, 3.0), v(5.0, 7.0), None, None, None).unwrap();
|
||||
let (la, _) = match sk.curves[l].kind {
|
||||
CurveKind::Line { a, b } => (a, b),
|
||||
_ => panic!(),
|
||||
};
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: la, q: pts[2] }, None).unwrap();
|
||||
let ids: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
assert!(!sk.update_link(&j, &sq(20.0)).unwrap(), "same shape updates in place");
|
||||
let ids2: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
assert_eq!(ids, ids2);
|
||||
assert!(solve(&mut sk).ok());
|
||||
assert!(sk.points[la].pos.dist(v(20.0, 20.0)) < 1e-9);
|
||||
assert_eq!(find_profiles(&sk).len(), 1);
|
||||
// A different shape rebuilds (the coincidence on the old corner goes).
|
||||
assert!(sk.update_link(&j, &[LinkGeom::Circle(v(1.0, 1.0), 4.0)]).unwrap());
|
||||
assert_eq!(sk.link_curves(&j).len(), 1);
|
||||
assert_eq!(sk.constraints.len(), 0);
|
||||
// Linked circles keep their radius in the solver.
|
||||
let c = sk.link_curves(&j)[0];
|
||||
let rep = solve(&mut sk);
|
||||
assert!(rep.curve_determined[c], "{rep:?}");
|
||||
// Breaking the link frees it.
|
||||
sk.break_link(&j).unwrap();
|
||||
assert!(sk.links.is_empty());
|
||||
assert!(sk.curves[c].link.is_none());
|
||||
let rep = solve(&mut sk);
|
||||
assert!(!rep.curve_determined[c]);
|
||||
// Deleting all of a link's curves removes the link.
|
||||
let j2 = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(v(0.0, 0.0), v(1.0, 0.0))]).unwrap();
|
||||
let cs = sk.link_curves(&j2);
|
||||
sk.remove_curves(&cs);
|
||||
assert!(sk.link(&j2).is_none());
|
||||
// Nothing to project is an error, not an empty link.
|
||||
assert!(sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(v(0.0, 0.0), v(0.0, 0.0))]).is_err());
|
||||
// Round trip through JSON.
|
||||
let j3 = sk.add_link(LinkKind::Intersect, LinkSource::Axis { name: "X".into() }, &[LinkGeom::Point(v(2.0, 2.0))]).unwrap();
|
||||
let back: Sketch = serde_json::from_str(&serde_json::to_string(&sk).unwrap()).unwrap();
|
||||
assert_eq!(back, sk);
|
||||
assert_eq!(back.link(&j3).unwrap().kind, LinkKind::Intersect);
|
||||
}
|
||||
+11
-11
@@ -2,13 +2,13 @@
|
||||
|
||||
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.
|
||||
|
||||
**SOLID + SKETCH: 57 / 291 commands live (20%).** All Design tabs: 137 / 1175 (12%). Registered commands: 78.
|
||||
**SOLID + SKETCH: 59 / 291 commands live (20%).** All Design tabs: 145 / 1175 (12%). Registered commands: 83.
|
||||
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
| SOLID | CREATE | 13 | 35 | 37% |
|
||||
| SOLID | MODIFY | 10 | 32 | 31% |
|
||||
| SOLID | ASSEMBLE | 0 | 21 | 0% |
|
||||
| SOLID | ASSEMBLE | 1 | 21 | 5% |
|
||||
| SOLID | CONFIGURE | 0 | 3 | 0% |
|
||||
| SOLID | CONSTRUCT | 2 | 20 | 10% |
|
||||
| SOLID | INSPECT | 1 | 25 | 4% |
|
||||
@@ -20,13 +20,13 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
| SKETCH | CONSTRAINTS | 11 | 15 | 73% |
|
||||
| SKETCH | CONFIGURE | 0 | 3 | 0% |
|
||||
| SKETCH | INSPECT | 1 | 25 | 4% |
|
||||
| SKETCH | INSERT | 0 | 13 | 0% |
|
||||
| SKETCH | INSERT | 1 | 13 | 8% |
|
||||
| SKETCH | ASSEMBLE | 0 | 1 | 0% |
|
||||
| SKETCH | SELECT | 0 | 15 | 0% |
|
||||
| SKETCH | FINISH SKETCH | 1 | 2 | 50% |
|
||||
| SURFACE | CREATE | 4 | 25 | 16% |
|
||||
| SURFACE | MODIFY | 7 | 26 | 27% |
|
||||
| SURFACE | ASSEMBLE | 0 | 20 | 0% |
|
||||
| SURFACE | ASSEMBLE | 1 | 20 | 5% |
|
||||
| SURFACE | CONFIGURE | 0 | 3 | 0% |
|
||||
| SURFACE | CONSTRUCT | 2 | 20 | 10% |
|
||||
| SURFACE | INSPECT | 1 | 25 | 4% |
|
||||
@@ -36,7 +36,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
| MESH | CREATE | 0 | 5 | 0% |
|
||||
| MESH | PREPARE | 0 | 4 | 0% |
|
||||
| MESH | MODIFY | 4 | 30 | 13% |
|
||||
| MESH | ASSEMBLE | 0 | 21 | 0% |
|
||||
| MESH | ASSEMBLE | 1 | 21 | 5% |
|
||||
| MESH | CONFIGURE | 0 | 3 | 0% |
|
||||
| MESH | CONSTRUCT | 2 | 20 | 10% |
|
||||
| MESH | INSPECT | 1 | 25 | 4% |
|
||||
@@ -50,13 +50,13 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
| FORM | UTILITIES | 0 | 5 | 0% |
|
||||
| FORM | CONSTRUCT | 2 | 20 | 10% |
|
||||
| FORM | INSPECT | 1 | 25 | 4% |
|
||||
| FORM | INSERT | 0 | 13 | 0% |
|
||||
| FORM | INSERT | 1 | 13 | 8% |
|
||||
| FORM | ASSEMBLE | 0 | 1 | 0% |
|
||||
| FORM | SELECT | 0 | 15 | 0% |
|
||||
| FORM | FINISH FORM | 0 | 1 | 0% |
|
||||
| SHEET METAL | CREATE | 6 | 19 | 32% |
|
||||
| SHEET METAL | MODIFY | 6 | 21 | 29% |
|
||||
| SHEET METAL | ASSEMBLE | 0 | 20 | 0% |
|
||||
| SHEET METAL | ASSEMBLE | 1 | 20 | 5% |
|
||||
| SHEET METAL | CONFIGURE | 0 | 3 | 0% |
|
||||
| SHEET METAL | CONSTRUCT | 2 | 20 | 10% |
|
||||
| SHEET METAL | INSPECT | 1 | 25 | 4% |
|
||||
@@ -67,14 +67,14 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
| PLASTIC | SETUP | 0 | 2 | 0% |
|
||||
| PLASTIC | CREATE | 13 | 35 | 37% |
|
||||
| PLASTIC | MODIFY | 10 | 32 | 31% |
|
||||
| PLASTIC | ASSEMBLE | 0 | 20 | 0% |
|
||||
| PLASTIC | ASSEMBLE | 1 | 20 | 5% |
|
||||
| PLASTIC | CONFIGURE | 0 | 3 | 0% |
|
||||
| PLASTIC | CONSTRUCT | 2 | 20 | 10% |
|
||||
| PLASTIC | INSPECT | 1 | 26 | 4% |
|
||||
| PLASTIC | INSERT | 0 | 11 | 0% |
|
||||
| PLASTIC | SELECT | 0 | 15 | 0% |
|
||||
| PLASTIC | POSITION | 0 | 2 | 0% |
|
||||
| ASSEMBLY | INSERT | 0 | 8 | 0% |
|
||||
| ASSEMBLY | INSERT | 1 | 8 | 12% |
|
||||
| ASSEMBLY | CREATE | 3 | 8 | 38% |
|
||||
| ASSEMBLY | RELATIONSHIPS | 0 | 11 | 0% |
|
||||
| ASSEMBLY | MOTION | 0 | 3 | 0% |
|
||||
@@ -125,6 +125,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SOLID › MODIFY › Delete (`FusionDeleteCommand`)
|
||||
- SOLID › MODIFY › Change Parameters (`ChangeParameterCommand`)
|
||||
- SOLID › MODIFY › Compute All (`FusionComputeAllCommand`)
|
||||
- SOLID › ASSEMBLE › Insert Component (`FusionImportCommandFromToolbar`)
|
||||
- SOLID › CONSTRUCT › Offset Plane (`ConstructionPlaneOffsetFromPlaneCommand`)
|
||||
- SOLID › CONSTRUCT › Plane at Angle (`ConstructionPlaneAtAngleCommand`)
|
||||
- SOLID › INSPECT › Measure (`MeasureCommand`)
|
||||
@@ -158,6 +159,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SKETCH › CONSTRAINTS › Collinear (`ConstraintCollinear`)
|
||||
- SKETCH › CONSTRAINTS › Symmetry (`ConstraintSymmetry`)
|
||||
- SKETCH › INSPECT › Measure (`MeasureCommand`)
|
||||
- SKETCH › INSERT › Insert Component (`FusionImportCommandFromToolbar`)
|
||||
- SKETCH › FINISH SKETCH › Finish Sketch (`SketchStop`)
|
||||
|
||||
## Not yet (SOLID, SKETCH)
|
||||
@@ -206,7 +208,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SOLID › MODIFY › Compute Unresolved (`FusionComputeUnresolvedCommand`)
|
||||
- SOLID › MODIFY › Convert (`TSpline2BRepCommand`)
|
||||
- SOLID › MODIFY › Bill of Materials (`MaterializedBomHybridCmd`)
|
||||
- SOLID › ASSEMBLE › Insert Component (`FusionImportCommandFromToolbar`)
|
||||
- SOLID › ASSEMBLE › New Component (`FusionCreateNewComponentCommand`)
|
||||
- SOLID › ASSEMBLE › Duplicate With Joints (`FusionDuplicateWithJointsCommand`)
|
||||
- SOLID › ASSEMBLE › Constrain Components (`AssemblyMateCommand`)
|
||||
@@ -372,7 +373,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SKETCH › INSERT › Insert SVG (`SketchImportSVG`)
|
||||
- SKETCH › INSERT › Insert DXF (`ImportDxfFileCommand`)
|
||||
- SKETCH › INSERT › Insert Mesh (`ParaMeshInsertAlignCommand`)
|
||||
- SKETCH › INSERT › Insert Component (`FusionImportCommandFromToolbar`)
|
||||
- SKETCH › INSERT › Insert Fastener (`FusionFastenersCommand`)
|
||||
- SKETCH › INSERT › Duplicate With Joints (`FusionDuplicateWithJointsCommand`)
|
||||
- SKETCH › INSERT › Insert Derive (`PullDeriveCommand`)
|
||||
|
||||
Reference in new issue
Block a user