Files
OneCAD/crates/engine/src/cmd/clipboard.rs
T
phanoob 56747edb46 Boolean: a point at a boundary curve's end is that end, whatever its parameter (text cuts, #391) (#1195)
A point within the point tolerance of a boundary curve's end is that end in the boolean, whatever its parameter, so text cuts no longer leave unmatched sliver vertices (#391). Robustness goldens re-recorded for seeds 0, 34, 39, 41 and 57 with reasons.
2026-10-15 13:31:37 -04:00

536 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Copy and paste of timeline features. Pasted features are independent copies: sketches get
//! their own dimension parameters, feature inputs their own names, and references inside the
//! copied set point at the copies.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
use crate::params::{bad, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.copy", "Copy Features", copy).noundo().params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.paste", "Paste Features", paste)
.params("translate?: [x,y,z] (moves the copies' geometry); the copies go at the marker"),
];
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.copy";
let mut keys = string_list(p, "features");
if let Some(Value::Array(a)) = p.get("features") {
keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string()));
}
if keys.is_empty() || keys.len() > 1000 {
return Err(bad(cmd, "`features` must list 1…1000 features"));
}
let mut ids = Vec::new();
for k in &keys {
ids.push(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
}
// Keep timeline order so sketches come before the features that use them.
let copied: Vec<solidcraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
let names: Vec<String> = copied.iter().map(|f| f.name.clone()).collect();
s.clipboard = copied;
Ok(json!({"copied": names}))
}
/// Origin planes become explicit planes so a translation can move them.
fn explicit_planes(p: &mut PlaneRef) {
match p {
PlaneRef::Origin { name } => {
if let Some(plane) = solidcraft_geom::Plane::named(name) {
*p = PlaneRef::Custom { plane };
}
}
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => explicit_planes(base),
_ => {}
}
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.paste";
if s.clipboard.is_empty() {
return Err(bad(cmd, "nothing copied (timeline.copy first)"));
}
let translate = match p.get("translate") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z]"))?),
None => None,
};
let feats = s.clipboard.clone();
let comp = s.active_component;
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
let mut used: std::collections::BTreeSet<String> = s.model.state().bodies.iter().map(|body| body.name.clone()).collect();
let mut notices = Vec::new();
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp));
solidcraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
let mut names: BTreeMap<String, String> = BTreeMap::new();
let mut made = Vec::new();
for f in feats {
let mut kind = f.kind.clone();
kind.remap_refs(&sketches, &names);
// A body made by an earlier copy is the one this copy acts on (#613).
kind.remap_bodies(&bodies);
if let FeatureKind::Sketch { plane, .. } = &mut kind
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
{
let internal = bodies.contains_key(&anchor.target.body);
if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) {
return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first"));
}
if !internal {
notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body));
}
anchor.remap_target(&sketches, &bodies)?;
if internal {
anchor.component = comp;
anchor.path = author_path.clone();
}
anchor.author_path = author_path.clone();
let target = anchor.target.clone();
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
for link in &mut sketch.links {
if link.auto_face {
if let solidcraft_sketch::LinkSource::Face { body, .. } = &mut link.source
&& let Some(mapped) = bodies.get(body)
{
*body = mapped.clone();
}
link.face_name = Some(target.name.clone());
}
}
}
}
let built: Vec<String> = s
.model
.result(f.id)
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect())
.unwrap_or_default();
let outputs = if f.body_names.is_empty() { built.clone() } else { f.body_names.clone() };
// A later feature names a body as the model shows it, and that is not always the name stored
// on the feature: solids that share a label are stored as `X`, `X`, `X` and shown as `X`,
// `X (2)`, `X (3)`, and a second import of a file shows `Aa (2)` for the stored `Aa`. The
// copies are found by the shown names; the stored ones only name the copies.
let shown = if built.len() == outputs.len() { built } else { outputs.clone() };
let copied_names: Vec<_> = outputs
.iter()
.zip(&shown)
.map(|(original, shown)| {
let base = format!("{original} (copy)");
let mut name = base.clone();
let mut suffix = 2usize;
while !used.insert(name.clone()) {
name = format!("{base} {suffix}");
suffix += 1;
}
bodies.insert(shown.clone(), name.clone());
name
})
.collect();
let doc = s.doc_mut();
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
// Each dimension of the copy gets its own parameter, with the same expression.
for c in &mut sketch.constraints {
if let Some(pn) = c.param.clone() {
let (e, u) = doc.param(&pn).map(|x| (x.expr.clone(), x.unit.clone())).unwrap_or_else(|| (pn.clone(), "mm".into()));
c.param = Some(doc.new_model_param(&e, &u));
}
}
}
if let Some(t) = translate {
if let FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } = &mut kind {
explicit_planes(plane);
}
let mut m = solidcraft_doc::IDENTITY;
m[3][0] = t.x;
m[3][1] = t.y;
m[3][2] = t.z;
kind.transform_geometry(&m);
}
// A cylinder Emboss finds its face only by the persistent name recorded when it was made
// (#1035): the copy keeps that name, pointed at the pasted features and bodies.
let face_names: Vec<String> = if matches!(kind, FeatureKind::Emboss { face: Some(_), .. }) {
f.face_names.iter().map(|n| solidcraft_doc::remap_topology(n, &sketches, &bodies)).collect()
} else {
Vec::new()
};
let id = doc.add_feature(kind, None)?;
if let Some(nf) = doc.feature_mut(id) {
nf.component = comp;
nf.body_names = copied_names;
nf.face_names = face_names;
}
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
sketches.insert(f.id, id);
names.insert(f.name.clone(), new_name.clone());
made.push(json!({"id": id, "name": new_name, "from": f.name}));
}
s.refresh();
let errors: Vec<Value> = made
.iter()
.filter_map(|m| m["id"].as_u64())
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
.collect();
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: serde_json::Value) -> serde_json::Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn copy_paste_sketch_and_extrude() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
assert!((volume(&mut s) - 500.0).abs() < 1e-6);
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [30, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 2);
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
// The copy's dimensions are its own, still driven by the user parameter.
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
let pasted_extrude = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let d = s.doc.find_feature(&pasted_extrude).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": d, "expression": "10"}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "only the copy got thicker");
assert!(s.execute("timeline.paste", &json!({"translate": "x"})).is_err());
let mut t = Session::default();
assert!(t.execute("timeline.paste", &json!({})).is_err());
}
#[test]
fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
run(&mut s, "timeline.copy", json!({"features": ["Source"]}));
run(&mut s, "timeline.roll_to", json!({"position": 1}));
let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap();
let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap();
let first = s.doc.find_feature("First").unwrap().id;
let second = s.doc.find_feature("Second").unwrap().id;
assert_eq!(folder.features, vec![first, pasted, second]);
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
}
/// A STEP file of three boxes at x = 0, 30 and 60. With `shared` the three solids carry one
/// label (`X`), as the parts of many assembly files do; otherwise each has its own.
fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
let corner = [30.0 * i as f64, 0.0, 0.0];
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": corner, "operation": "new_body", "body_name": label}));
}
let path = dir.join("Three.step");
run(&mut s, "file.export", json!({"path": path.to_string_lossy()}));
if shared {
let mut text = std::fs::read_to_string(&path).unwrap();
for label in ["Aa", "Bb", "Cc"] {
assert!(text.contains(&format!("MANIFOLD_SOLID_BREP('{label}'")), "the file names its solids: {label}");
text = text.replace(&format!("MANIFOLD_SOLID_BREP('{label}'"), "MANIFOLD_SOLID_BREP('X'");
}
std::fs::write(&path, text).unwrap();
}
path
}
/// An OBJ file of three 10 mm cubes at x = 0, 30 and 60, every object called `X`.
fn three_cube_obj(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let faces =
[[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5]];
let mut text = String::new();
for i in 0..3 {
text += "o X\n";
for c in corners {
text += &format!("v {} {} {}\n", 30 * i + 10 * c[0], 10 * c[1], 10 * c[2]);
}
for f in faces {
text += &format!("f {} {} {}\n", 8 * i + f[0] + 1, 8 * i + f[1] + 1, 8 * i + f[2] + 1);
}
}
let path = dir.join("Three.obj");
std::fs::write(&path, text).unwrap();
path
}
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
/// Where every body is: the lowest corner of its bounding box, rounded, in model order.
fn corners(s: &mut Session) -> Vec<Vec<f64>> {
let m = run(s, "inspect.measure", json!({}));
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
bodies
.iter()
.map(|b| b["bbox"]["min"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64().map(f64::round)).collect()).unwrap_or_default())
.collect()
}
/// Lifts the first of the three bodies of the import by 20 and the second by 50 (two features),
/// copies the import with both and pastes them: the copies must be lifted, the originals once.
fn paste_import_with_two_lifts(s: &mut Session, import: &Value, bodies: [&str; 3]) {
run(s, "solid.move", json!({"bodies": [bodies[0]], "translate": [0, 0, 20], "name": "Lift first"}));
run(s, "solid.move", json!({"bodies": [bodies[1]], "translate": [0, 0, 50], "name": "Lift second"}));
run(s, "timeline.copy", json!({"features": [import, "Lift first", "Lift second"]}));
let r = run(s, "timeline.paste", json!({}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
}
const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]];
/// A control: three solids with labels of their own. The pasted features act on the pasted bodies.
#[test]
fn pasting_an_import_moves_the_copies_of_the_bodies_its_features_named() {
let path = three_box_step("distinct", false);
let mut s = Session::default();
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]);
paste_import_with_two_lifts(&mut s, &import, ["Aa", "Bb", "Cc"]);
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (copy)", "Bb (copy)", "Cc (copy)"]);
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at);
}
/// Solids of a STEP file that share one label are built as `X`, `X (2)`, `X (3)`, and the features
/// made on them name them so. The pasted features act on the copies of those bodies, each on its
/// own: `X (2)` is not left on the original, and `X` does not go to the last copy.
#[test]
fn pasting_an_import_with_one_label_on_all_solids_moves_the_copy_of_each_body() {
let path = three_box_step("shared", true);
let mut s = Session::default();
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
let names = body_names(&s);
assert_eq!(names.len(), 6, "{names:?}");
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{names:?}");
// The copies keep their names and their places when the design is saved and opened again.
let design = path.with_file_name("Three.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut r = Session::default();
run(&mut r, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&r), names);
assert_eq!(corners(&mut r), at);
}
/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import).
#[test]
fn pasting_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_each_body() {
let path = three_cube_obj("shared-obj");
let mut s = Session::default();
let import = run(&mut s, "file.insert_mesh", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
}
/// No shared label is needed: a second import of the same file builds `Aa (2)`, `Bb (2)`, `Cc (2)`
/// from the labels `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so.
#[test]
fn pasting_a_second_import_of_a_file_moves_the_copies_of_its_bodies() {
let path = three_box_step("twice", false);
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}));
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
// Each import is a component of its own (#1386), shown after the pasted copies: the first
// import's bodies stay where they were, the second's and their copies are lifted.
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&plain).chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
let m = run(&mut s, "inspect.measure", json!({}));
let names: Vec<&str> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect();
assert_eq!(names, ["Aa (copy)", "Bb (copy)", "Cc (copy)", "Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
}
/// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The
/// pasted Emboss keeps that name, moved onto the pasted cylinder, so the copy gets its own
/// badge, with or without a translate (#1035).
#[test]
fn pasted_cylinder_emboss_badges_the_pasted_cylinder() {
for translate in [Some(json!([50, 0, 0])), None] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": "10 mm", "height": "20 mm", "body_name": "Sleeve", "name": "Tube"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [-1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-2, 5], "p1": [2, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Badge", "face": [0, 10, 7.5], "depth": 1, "targets": ["Sleeve"], "name": "Mark"}));
let one = volume(&mut s);
let plain = std::f64::consts::PI * 100.0 * 20.0;
assert!(one > plain + 1.0, "the badge adds material: {one}");
run(&mut s, "timeline.copy", json!({"features": ["Tube", "Badge", "Mark"]}));
let p = translate.clone().map_or(json!({}), |t| json!({"translate": t}));
let r = run(&mut s, "timeline.paste", p);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{translate:?}: {r}");
let mark = s.doc.find_feature(r["pasted"][2]["name"].as_str().unwrap_or_default()).cloned().unwrap();
let tube = r["pasted"][0]["id"].as_u64().unwrap();
assert!(mark.face_names.first().is_some_and(|n| n.starts_with(&format!("F{tube}:"))), "{:?}", mark.face_names);
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "{translate:?}: both cylinders carry a badge");
if translate.is_some() {
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Sleeve (copy)"]}));
let body = &m["bodies"][0];
let x = body["center_of_mass"][0].as_f64().unwrap_or(f64::NAN);
let y = body["bbox"]["max"][1].as_f64().unwrap_or(f64::NAN);
assert!((x - 50.0).abs() < 1e-3 && (y - 11.0).abs() < 1e-6, "the copy's badge sits on the moved cylinder: {m}");
}
}
}
#[test]
fn pasted_fillet_rounds_the_copied_body_it_names() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10, "operation": "new", "body_name": "Block", "name": "Block"}));
run(&mut s, "solid.fillet", json!({"body": "Block", "radius": 2, "edges": [[20, 0, 10], [20, 20, 10]], "name": "Round"}));
let one = volume(&mut s);
assert!(one < 8000.0 - 1.0, "the fillet removes material: {one}");
run(&mut s, "timeline.copy", json!({"features": ["S", "Block", "Round"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [0, 40, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let round = r["pasted"][2]["name"].as_str().unwrap_or_default().to_string();
let def = run(&mut s, "timeline.inspect", json!({"feature": round}));
assert_eq!(def["definition"]["body"], "Block (copy)", "{def}");
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 2, "{m}");
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}");
}
/// One body's measures (bounding box, volume).
fn measures(s: &mut Session, body: &str) -> serde_json::Value {
run(s, "inspect.measure", json!({"bodies": [body]}))["bodies"][0].clone()
}
/// Pasting with a translate moves an extrude's `to` and `from` picks with it: the pasted posts
/// run to the pasted roof and from the pasted shelf, not to and from the originals.
#[test]
fn a_pasted_extrude_runs_to_and_from_the_pasted_faces() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 20], "name": "Roof", "body_name": "Roof"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 10], "name": "Shelf", "body_name": "Shelf"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "to": [7, 7, 20], "operation": "new", "name": "Up", "body_name": "Up"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [25, 25], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch": "T", "distance": 5, "from": [27, 27, 12], "operation": "new", "name": "On", "body_name": "On"}),
);
run(&mut s, "timeline.copy", json!({"features": ["Roof", "Shelf", "S", "Up", "T", "On"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [100, 0, 10]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let up = measures(&mut s, "Up (copy)");
assert!((up["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 10.0).abs() < 1e-6, "{up}");
assert!((up["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "it ends on the pasted roof: {up}");
let on = measures(&mut s, "On (copy)");
assert!((on["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 22.0).abs() < 1e-6, "it starts on the pasted shelf: {on}");
assert!((on["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 27.0).abs() < 1e-6, "{on}");
}
/// Pasting with a translate moves a hole's `to` pick with it: the pasted through hole still goes
/// through, rather than stopping at the original's depth.
#[test]
fn a_pasted_hole_drills_to_the_moved_point() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "name": "Block", "body_name": "Block"}));
run(&mut s, "solid.hole", json!({"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": 10, "to": [30, 30, -2], "name": "Drill"}));
run(&mut s, "timeline.copy", json!({"features": ["Block", "Drill"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [60, 0, -6]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let (block, copy) = (measures(&mut s, "Block"), measures(&mut s, "Block (copy)"));
let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN);
assert!((v(&block) - (16000.0 - std::f64::consts::PI * 25.0 * 10.0)).abs() < 1.0, "a through hole: {block}");
assert!((v(&copy) - v(&block)).abs() < 1e-6, "the pasted hole goes through too: {copy}");
}
/// Pasting with a translate moves a revolve's `to` pick with it: the pasted turn ends at the same
/// angle about its moved axis.
#[test]
fn a_pasted_revolve_turns_to_the_moved_point() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "y", "to": [0, 12, 2], "name": "Turn", "body_name": "Arc"}));
run(&mut s, "timeline.copy", json!({"features": ["S", "Turn"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let (arc, copy) = (measures(&mut s, "Arc"), measures(&mut s, "Arc (copy)"));
let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN);
// A quarter turn of a 5 × 5 section whose centre is 12.5 from the axis.
let quarter = 25.0 * std::f64::consts::FRAC_PI_2 * 12.5;
assert!((v(&arc) - quarter).abs() < quarter * 1e-3, "{arc}");
assert!((v(&copy) - v(&arc)).abs() < 1e-6, "the pasted arc turns a quarter too: {copy}");
}
/// A pasted coil that joins a body made by an earlier copy joins that copy, not the original.
#[test]
fn a_pasted_coil_joins_the_pasted_body() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "name": "Base", "body_name": "Plate"}));
run(
&mut s,
"solid.coil",
json!({"diameter": 16, "pitch": 6, "revolutions": 3, "section_size": 2, "base": [15, 15, 5], "operation": "join", "targets": ["Plate"], "name": "Spring"}),
);
run(&mut s, "timeline.copy", json!({"features": ["Base", "Spring"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let spring = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let def = run(&mut s, "timeline.inspect", json!({"feature": spring}));
assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}");
let st = s.world_state();
let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
// A spring on the wrong plate is hundreds of mm³ off. The two plates are measured apart
// (one moved 50 mm), which the boolean and the measure each round in their own way:
// a few 1e-6 mm³ on 5000.
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-8 * v("Plate"), "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
}
}