diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 83cb3b7331..7e1b1b9fad 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -176,6 +176,8 @@ pub(crate) fn remap_link( remap_link(b, ids, names, bodies, depth + 1); } LinkSource::Plane { name } => mapped_name(name, names), + // A construction axis, by name (#9324); the origin axes are no feature's. + LinkSource::Axis { name } if !matches!(name.as_str(), "X" | "Y" | "Z") => mapped_name(name, names), _ => {} } } diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 0889952654..677e68aa60 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -238,13 +238,14 @@ fn resolve_uncached( } 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, d) = match name.to_ascii_uppercase().as_str() { + "X" => (Vec3::ZERO, Vec3::X), + "Y" => (Vec3::ZERO, Vec3::Y), + "Z" => (Vec3::ZERO, Vec3::Z), + // A construction axis, by name (#9324), where the timeline so far puts it. + _ => crate::eval::construction_axis_in(Some(doc), st, name, 0)?, }; - let o = plane.to_local(Vec3::ZERO); + let o = plane.to_local(o); 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)], diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 6bbdbad5e7..d372a3e292 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -16,7 +16,7 @@ use crate::{EngineError, Result, Session}; macro_rules! refs_doc { () => { - "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}]" + "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|name} | {plane: XY|XZ|YZ|name}]" }; } @@ -110,7 +110,7 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result { return Ok(LinkSource::Body { body: b.to_string() }); } if let Some(a) = str_(v, "axis") { - return Ok(LinkSource::Axis { name: a.to_ascii_uppercase() }); + return axis_source(s, a, cmd); } if let Some(p) = str_(v, "plane") { return Ok(LinkSource::Plane { name: p.to_string() }); @@ -121,6 +121,17 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result { Err(bad(cmd, format!("cannot project {v}"))) } +/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324). +fn axis_source(s: &Session, name: &str, cmd: &str) -> Result { + if ["X", "Y", "Z"].iter().any(|axis| name.eq_ignore_ascii_case(axis)) { + return Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }); + } + match s.doc.find_feature(name) { + Some(f) if matches!(f.kind, FeatureKind::ConstructionAxis { .. }) => Ok(LinkSource::Axis { name: f.name.clone() }), + _ => Err(bad(cmd, format!("unknown axis `{name}`: X, Y, Z or the name of a construction axis"))), + } +} + /// The plane of a sketch feature, resolved against `st`: the model before the sketch as its /// component sees it, which is what evaluation resolves it against. A construction plane built /// from the model (through a sketch line, three points, between two faces…) exists only there. diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 7e94c9c4cc..a17c1fa40d 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -216,6 +216,53 @@ fn project_edges_bodies_sketches_and_origin() { assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing"); } +/// A construction axis projects as an origin axis does, as its line in the sketch plane, and stays +/// linked to it: the projection follows when the axis moves and when it is renamed. The report's +/// steps (#9324): the axis made after the sketch, then moved before it. +#[test] +fn a_construction_axis_projects_and_stays_linked() { + let mut s = Session::default(); + let sketch = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let axis = |x: f64| json!({"a": {"point": [x, 0, 0]}, "b": {"point": [x, 30, 0]}, "name": "Axis1"}); + run(&mut s, "construct.axis.two_points", axis(10.0)); + run(&mut s, "timeline.reorder", json!({"feature": "Axis1", "position": 0})); + run(&mut s, "sketch.edit", json!({"sketch": sketch})); + let r = run(&mut s, "sketch.project", json!({"refs": [{"axis": "Axis1"}]})); + let line = r["links"][0]["curves"][0].clone(); + // Where the projected line runs: its two ends' x, and its length along y. + let at = |s: &mut Session| { + run(s, "sketch.edit", json!({"sketch": sketch})); + let si = inspect(s); + let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == line).cloned().unwrap(); + run(s, "sketch.finish", json!({})); + let n = |v: &Value| v.as_f64().unwrap(); + (n(&c["start_at"][0]), n(&c["end_at"][0]), (n(&c["end_at"][1]) - n(&c["start_at"][1])).abs()) + }; + run(&mut s, "sketch.finish", json!({})); + let (x0, x1, length) = at(&mut s); + assert!((x0 - 10.0).abs() < 1e-9 && (x1 - 10.0).abs() < 1e-9 && length > 30.0, "{x0} {x1} {length}"); + run(&mut s, "timeline.redefine", json!({"feature": "Axis1", "command": "construct.axis.two_points", "params": axis(25.0)})); + let (x0, x1, _) = at(&mut s); + assert!((x0 - 25.0).abs() < 1e-9 && (x1 - 25.0).abs() < 1e-9, "the projection follows the axis: {x0} {x1}"); + run(&mut s, "timeline.rename", json!({"feature": "Axis1", "name": "Hinge axis"})); + let state = s.model.state(); + let link = &state.sketch(sketch).unwrap().sketch.links[0]; + assert_eq!(link.source, solidcraft_sketch::LinkSource::Axis { name: "Hinge axis".into() }); + assert!(!link.lost, "renamed, the axis is still found"); + // A design with the axis after the sketch opens with the link lost, saying why. + run(&mut s, "timeline.reorder", json!({"feature": "Hinge axis", "position": 1})); + let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(opened.model.state().sketch(sketch).unwrap().sketch.links[0].lost); + let warnings = opened.model.result(sketch).and_then(|r| r.warning.clone()).unwrap_or_default(); + assert!(warnings.contains("it must come earlier in the timeline"), "{warnings}"); + run(&mut s, "timeline.reorder", json!({"feature": "Hinge axis", "position": 0})); + // An axis that is no origin axis and no construction axis is refused, by its name. + run(&mut s, "sketch.edit", json!({"sketch": sketch})); + let error = s.execute("sketch.project", &json!({"refs": [{"axis": "Axis9"}]})).unwrap_err().to_string(); + assert!(error.contains("unknown axis `Axis9`"), "{error}"); +} + #[test] fn project_whole_body_can_create_unlinked_geometry() { let mut s = Session::default();