Sketch: Project takes a construction axis, not only X, Y and Z (#9324) (#9471)

* claim: #9324

* Sketch: Project takes a construction axis, not only X, Y and Z (#9324)

sketch.project with {"axis": "Axis1"} upper-cased the name, and the axis link knew only the
origin axes, so a construction axis failed with "unknown axis `AXIS1`". X, Y and Z (any case)
are still the origin axes; any other name must be a construction axis, kept as its feature names
it. The link finds the axis where the timeline before the sketch puts it (construction_axis_in,
as Revolve does) and projects it as an origin axis: its line in the sketch plane, or a point
when the axis stands square to it. Renaming the axis renames the link, as for a construction
plane's projection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
austin62jake
2026-10-30 15:51:57 +00:00
committed by GitHub
parent f0d5666a74
commit 3b3f860adc
4 changed files with 69 additions and 8 deletions
+2
View File
@@ -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),
_ => {}
}
}
+7 -6
View File
@@ -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)],
+13 -2
View File
@@ -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<LinkSource> {
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<LinkSource> {
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<LinkSource> {
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.
@@ -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();