Files
OneCAD/crates/engine/src/cmd/sketch_project_edge_tests.rs
T
NOWN-DEV 58af8accfa fix: a projected edge stays the edge that was picked when its body changes (#730)
A sketch's link to a projected edge now keeps the edge's persistent name and finds it by that name when the body changes, instead of taking whichever edge ends up nearest its point.
2026-10-13 12:06:13 -04:00

229 lines
12 KiB
Rust

//! A projected edge stays the edge that was picked: its link keeps the edge's persistent name,
//! so the edge is found by that name wherever a change of the body takes it, and no other edge
//! is taken for it when it is split or gone.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_sketch::{Link, LinkSource, Shape};
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}"),
}
}
/// A plate `width` (100) x 50 x 20 with a corner at the origin: body `Body1`.
fn plate(s: &mut Session) {
run(s, "parameters.add", json!({"name": "width", "expression": "100 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "width"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "20 mm", "name": "Plate"}));
}
/// Sketch `Top` on the plate's top (z = 20) with the top edge of the plate's right-hand side,
/// from (100, 0, 20) to (100, 50, 20), projected into it: picked at `y` along it.
fn project_right_edge(s: &mut Session, y: f64) {
run(s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
run(s, "sketch.project", json!({"refs": [{"edge": [100, y, 20], "body": "Body1"}]}));
run(s, "sketch.finish", json!({}));
}
fn set_width(s: &mut Session, width: f64) {
run(s, "parameters.change", json!({"name": "width", "expression": format!("{width} mm")}));
}
/// What sketch `Top` has to report.
fn trouble(s: &Session) -> String {
let r = s.model.result(s.doc.find_feature("Top").unwrap().id).unwrap();
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
}
/// The link of sketch `Top` as the model has it.
fn link(s: &Session) -> Link {
s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch.links[0].clone()
}
/// The link of sketch `Top` as the design stores it.
fn stored(s: &Session) -> Link {
s.doc.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().links[0].clone()
}
/// The ends of the line the link of sketch `Top` gives.
fn projected(s: &Session) -> (Vec2, Vec2) {
let st = s.model.state();
let sk = &st.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch;
match sk.shape(sk.link_curves(&sk.links[0].id)[0]) {
Some(Shape::Line { a, b }) => (a, b),
other => panic!("not a line: {other:?}"),
}
}
/// The projected line runs along y at `x`, between `y0` and `y1`.
#[track_caller]
fn assert_line(s: &Session, x: f64, (y0, y1): (f64, f64), when: &str) {
let (a, b) = projected(s);
let ends = (a.y.min(b.y), a.y.max(b.y));
assert!(
(a.x - x).abs() < 1e-6 && (b.x - x).abs() < 1e-6 && (ends.0 - y0).abs() < 1e-6 && (ends.1 - y1).abs() < 1e-6,
"{when}: expected the line x = {x}, y = {y0}..{y1}: {a:?} {b:?}"
);
}
/// The name of the edge of `Body1` whose middle is `mid`.
fn edge_named_at(s: &mut Session, mid: [f64; 3]) -> String {
let edges = run(s, "model.edges", json!({"body": "Body1"}));
let at = |e: &Value| Vec3::new(e["mid"][0].as_f64().unwrap(), e["mid"][1].as_f64().unwrap(), e["mid"][2].as_f64().unwrap());
let edge = edges["edges"].as_array().unwrap().iter().find(|e| at(e).dist(Vec3::new(mid[0], mid[1], mid[2])) < 1e-6).unwrap();
edge["name"].as_str().unwrap().to_string()
}
/// The picked point (100, 25, 20) is 25 from the front and back edges of the top: wider than
/// 125 they are nearer to it than the picked edge is. At 60 the picked edge is 40 from it,
/// further than a reference found by its point is looked for (a quarter of the body's diagonal,
/// 20.2). The link follows the edge by its name through all of them.
#[test]
fn a_projected_edge_stays_that_edge_when_the_body_changes_size() {
let mut s = Session::default();
plate(&mut s);
project_right_edge(&mut s, 25.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
for width in [150.0, 60.0, 400.0] {
set_width(&mut s, width);
assert_eq!(trouble(&s), "", "width {width}");
assert_line(&s, width, (0.0, 50.0), &format!("width {width}"));
assert!(!link(&s).lost, "width {width}");
}
// Saved and opened again.
let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(stored(&opened).edge_name.as_deref(), Some(name.as_str()));
assert_line(&opened, 400.0, (0.0, 50.0), "opened");
// Undo and redo of the change.
run(&mut s, "edit.undo", json!({}));
assert_line(&s, 60.0, (0.0, 50.0), "undone");
run(&mut s, "edit.redo", json!({}));
assert_line(&s, 400.0, (0.0, 50.0), "redone");
// Editing the sketch keeps the edge, and stores the link's point where the edge is now.
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(trouble(&s), "");
assert_line(&s, 400.0, (0.0, 50.0), "edited");
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
assert!(matches!(&stored(&s).source, LinkSource::Edge { body, at } if body == "Body1" && at.dist(Vec3::new(400.0, 25.0, 20.0)) < 1e-6));
}
/// A slot cut through the plate from x = 80 to 120, y = 20 to 30 leaves the picked edge in two
/// pieces, y = 0..20 and y = 30..50. The link, picked at y = 10, gives the piece it was picked
/// on and says the edge was split; without the slot it is the whole edge again.
#[test]
fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [80, 20], "p1": [120, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": "20 mm", "operation": "cut", "name": "Slot"}));
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
project_right_edge(&mut s, 10.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": false}));
assert_line(&s, 100.0, (0.0, 20.0), "split");
assert!(trouble(&s).contains("was split"), "{}", trouble(&s));
assert!(!link(&s).lost);
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
assert_line(&s, 100.0, (0.0, 50.0), "whole again");
assert_eq!(trouble(&s), "");
}
/// A fillet of radius 4 takes the picked edge away. Its two tangent edges are 4 from the picked
/// point, the nearest edges there are now: the link is lost and keeps its line, it takes
/// neither of them. Without the fillet the link has its edge again.
#[test]
fn a_projected_edge_that_is_gone_is_lost_not_replaced() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
project_right_edge(&mut s, 25.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
let said = trouble(&s);
assert!(said.contains("projected reference lost: edge") && said.contains("no longer exists"), "{said}");
assert!(link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "the last geometry is kept");
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "found again");
}
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
#[test]
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "BossSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "BossSketch", "distance": "10 mm", "operation": "new", "name": "Boss", "body_name": "Boss"}));
run(&mut s, "solid.combine", json!({"target": "Body1", "tools": ["Boss"], "operation": "join"}));
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Top"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [30, 20, 30], "body": "Boss"}]}));
run(&mut s, "sketch.finish", json!({}));
assert_line(&s, 30.0, (10.0, 30.0), "drawn");
assert!(stored(&s).edge_name.is_some());
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": false}));
assert!(s.model.state().body("Boss").is_none(), "the boss is part of the plate now");
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
assert_line(&s, 30.0, (10.0, 30.0), "joined");
assert!(matches!(&link(&s).source, LinkSource::Edge { body, .. } if body == "Body1"));
}
/// A design saved before links kept the edge's name (format 16) has a link with a point only.
/// It opens and is found by that point, as it was, and takes the edge's name when its sketch is
/// next edited: undoing the edit takes the name away again, redoing it brings it back.
#[test]
fn an_edge_link_saved_without_a_name_is_found_as_before_and_named_by_the_next_edit() {
let mut s = Session::default();
plate(&mut s);
project_right_edge(&mut s, 25.0);
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let links = saved["features"].as_array_mut().unwrap().iter_mut().filter_map(|f| f.pointer_mut("/sketch/links")?.as_array_mut());
let removed = links.flatten().filter_map(|l| l.as_object_mut()?.remove("edge_name")).count();
assert_eq!(removed, 1, "the one projected edge");
saved["format"] = json!("solidcraft/16");
let mut s = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
assert_eq!(s.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
assert_eq!(stored(&s).edge_name, None);
assert_eq!(trouble(&s), "");
assert_line(&s, 100.0, (0.0, 50.0), "opened");
// Found by its point: 0.2 further out the picked edge is still the nearest one.
set_width(&mut s, 100.2);
assert_eq!(stored(&s).edge_name, None);
assert_eq!(trouble(&s), "");
assert_line(&s, 100.2, (0.0, 50.0), "width 100.2");
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(stored(&s).edge_name, None);
run(&mut s, "edit.redo", json!({}));
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
run(&mut s, "sketch.finish", json!({}));
set_width(&mut s, 150.0);
assert_eq!(trouble(&s), "");
assert_line(&s, 150.0, (0.0, 50.0), "width 150");
}