Construct: plane through an axis and a point (#10178) (#10472)

* claim: #10178

* feat: add plane through axis and point (#10178)
This commit is contained in:
PapaEcho
2026-11-01 09:15:25 +00:00
committed by GitHub
parent f25a8e6abc
commit ce72abacf9
24 changed files with 361 additions and 16 deletions
@@ -181,6 +181,7 @@ pipe false
plane false
plane_along_path false
plane_angle false
plane_axis_point false
plane_midplane false
plane_perpendicular false
plane_tangent false
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Plane through axis and point</title>
<path d="M26 15L56 32.32L38 42.71L8 25.39Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
<path d="M15 18.4L53 40.34" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="22" cy="29" r="3.1" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

@@ -0,0 +1,34 @@
{
"en": {
"cmd.construct.plane.axis_point": "Plane Through Axis And Point",
"help.construct.plane.axis_point": "Creates a construction plane containing an axis and a point off that axis. Pick an origin or construction axis, a straight edge or sketch line, then a vertex, sketch point or construction point. A point on the axis cannot define a unique plane."
},
"ar": {
"cmd.construct.plane.axis_point": "مستوى عبر محور ونقطة",
"help.construct.plane.axis_point": "ينشئ مستوى إنشاء يحتوي على محور ونقطة خارجه. اختر محور الأصل أو محور إنشاء أو حافة مستقيمة أو خط رسم، ثم رأسًا أو نقطة رسم أو نقطة إنشاء. لا تحدد نقطة على المحور مستوى فريدًا."
},
"he": {
"cmd.construct.plane.axis_point": "מישור דרך ציר ונקודה",
"help.construct.plane.axis_point": "יוצר מישור עזר המכיל ציר ונקודה שאינה עליו. יש לבחור ציר ראשי או ציר עזר, קצה ישר או קו סקיצה, ואז קודקוד, נקודת סקיצה או נקודת עזר. נקודה על הציר אינה מגדירה מישור יחיד."
},
"ja": {
"cmd.construct.plane.axis_point": "軸と点を通る平面",
"help.construct.plane.axis_point": "軸と、その軸上にない点を含む構築平面を作成します。原点軸、構築軸、直線エッジまたはスケッチの直線を選択し、次に頂点、スケッチ点または構築点を選択します。軸上の点では平面が一意に定まりません。"
},
"vi": {
"cmd.construct.plane.axis_point": "Mặt phẳng qua trục và điểm",
"help.construct.plane.axis_point": "Tạo mặt phẳng dựng chứa một trục và một điểm nằm ngoài trục đó. Chọn trục gốc, trục dựng, cạnh thẳng hoặc đường thẳng phác thảo, rồi chọn đỉnh, điểm phác thảo hoặc điểm dựng. Điểm nằm trên trục không xác định được một mặt phẳng duy nhất."
},
"zh": {
"cmd.construct.plane.axis_point": "通过轴和点的平面",
"help.construct.plane.axis_point": "创建包含一条轴和一个轴外点的构造平面。先选择原点轴、构造轴、直边或草图直线,再选择顶点、草图点或构造点。轴上的点无法确定唯一的平面。"
},
"de": {
"cmd.construct.plane.axis_point": "Ebene durch Achse und Punkt",
"help.construct.plane.axis_point": "Erstellt eine Konstruktionsebene durch eine Achse und einen Punkt außerhalb der Achse. Zuerst eine Ursprungs- oder Konstruktionsachse, gerade Kante oder Skizzenlinie wählen, dann einen Eckpunkt, Skizzenpunkt oder Konstruktionspunkt. Ein Punkt auf der Achse legt keine eindeutige Ebene fest."
},
"ko": {
"cmd.construct.plane.axis_point": "축과 점을 지나는 평면",
"help.construct.plane.axis_point": "축과 축 위에 있지 않은 점을 포함하는 구성 평면을 만듭니다. 원점 축, 구성 축, 직선 모서리 또는 스케치 직선을 선택한 다음 꼭짓점, 스케치 점 또는 구성점을 선택합니다. 축 위의 점으로는 평면을 하나로 결정할 수 없습니다."
}
}
+1
View File
@@ -0,0 +1 @@
| assets/icons/ui/design/svg/model/plane_axis_point.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
+1
View File
@@ -208,6 +208,7 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::AxisPoint { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
+1 -1
View File
@@ -316,7 +316,7 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
checked_geo(g, depth + 1)?;
}
}
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } | PlaneRef::AxisPoint { axis: a, point: b } => {
checked_geo(a, depth + 1)?;
checked_geo(b, depth + 1)?;
}
+11 -1
View File
@@ -620,6 +620,16 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
};
Plane::new(o1, d1, n.cross(d1)).ok_or_else(degenerate)
}
PlaneRef::AxisPoint { axis, point: at } => {
let (origin, dir) = line(ctx, axis)?;
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
let delta = point(ctx, at)? - origin;
let across = delta - dir * delta.dot(dir);
if !across.len().is_finite() || across.len() < 1e-9 {
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
}
Plane::new(origin, dir, across).ok_or_else(degenerate)
}
PlaneRef::ThreePoints { a, b, c } => {
let (pa, pb, pc) = (point(ctx, a)?, point(ctx, b)?, point(ctx, c)?);
if (pb - pa).cross(pc - pa).len() < 1e-9 {
@@ -768,7 +778,7 @@ impl PlaneRef {
/// The references of a plane built from model geometry.
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } | PlaneRef::AxisPoint { axis: a, point: b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::Perpendicular { plane, line } => vec![plane.as_mut(), line.as_mut()],
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
+3
View File
@@ -97,6 +97,8 @@ pub enum PlaneRef {
Midplane { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through a line (axis) and a point not on that line.
AxisPoint { axis: Box<GeoRef>, point: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
@@ -3976,6 +3978,7 @@ impl Document {
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::AxisPoint { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. }
+1
View File
@@ -8198,6 +8198,7 @@ fn plane_reads_geometry(plane: &PlaneRef) -> bool {
PlaneRef::ThroughPoint { .. }
| PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::AxisPoint { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. }
+1
View File
@@ -202,6 +202,7 @@ fn construction_names_follow_renames_in_every_definition() {
let planes = [
json!({"type": "midplane", "a": plane, "b": plane}),
json!({"type": "two_edges", "a": axis, "b": axis}),
json!({"type": "axis_point", "axis": axis, "point": point}),
json!({"type": "three_points", "a": point, "b": point, "c": point}),
json!({"type": "tangent", "face": plane, "at": [0, 0, 0]}),
json!({"type": "along_path", "path": axis, "t": "0.5"}),
@@ -0,0 +1,3 @@
//! Construction planes can store an associative axis and off-axis point reference.
//! Older designs keep their existing plane definitions unchanged. The last release
//! asks for an update rather than opening a design with this new plane definition.
+2 -2
View File
@@ -406,7 +406,7 @@ impl PlaneRef {
g.walk_feature_refs(f, depth + 1);
}
}
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::AxisPoint { axis: a, point: b } | Self::Perpendicular { plane: a, line: b } => {
a.walk_feature_refs(f, depth + 1);
b.walk_feature_refs(f, depth + 1);
}
@@ -437,7 +437,7 @@ impl PlaneRef {
g.walk_feature_refs_mut(f, depth + 1);
}
}
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::AxisPoint { axis: a, point: b } | Self::Perpendicular { plane: a, line: b } => {
a.walk_feature_refs_mut(f, depth + 1);
b.walk_feature_refs_mut(f, depth + 1);
}
+22 -2
View File
@@ -16,8 +16,8 @@ use super::features::{add_feature, check_expr, plane_param, sketch_id};
use crate::params::{bad, req_expr, str_, vec3};
use crate::schema::construct::{
AxisEdgeParams, AxisNormalToFaceParams, AxisThroughCylinderParams, AxisTwoPlanesParams, AxisTwoPointsParams, MidplaneParams,
PlaneAlongPathParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams, PointAlongPathParams,
PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams,
PlaneAlongPathParams, PlaneAxisPointParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams,
PointAlongPathParams, PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams,
};
use crate::schema::features::FeatureResult;
use crate::schema::{TypedContract, output_schema_for};
@@ -39,6 +39,7 @@ const POINT_EDGE_PLANE_CONTRACT: TypedContract<PointEdgePlaneParams> = TypedCont
const POINT_ALONG_PATH_CONTRACT: TypedContract<PointAlongPathParams> = TypedContract::new();
const PLANE_MIDPLANE_CONTRACT: TypedContract<MidplaneParams> = TypedContract::new();
const PLANE_TWO_EDGES_CONTRACT: TypedContract<PlaneTwoEdgesParams> = TypedContract::new();
const PLANE_AXIS_POINT_CONTRACT: TypedContract<PlaneAxisPointParams> = TypedContract::new();
const PLANE_THREE_POINTS_CONTRACT: TypedContract<PlaneThreePointsParams> = TypedContract::new();
const PLANE_TANGENT_CONTRACT: TypedContract<PlaneTangentParams> = TypedContract::new();
const PLANE_ALONG_PATH_CONTRACT: TypedContract<PlaneAlongPathParams> = TypedContract::new();
@@ -123,6 +124,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PLANE_TWO_EDGES_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b: straight edge or line refs in one plane; name?"),
CommandSpec::new("construct.plane.axis_point", "Plane Through Axis And Point", crate::checked!("construct.plane.axis_point", plane_axis_point))
.at("SOLID", "CONSTRUCT")
.icon("plane_axis_point")
.contract(&PLANE_AXIS_POINT_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("axis: origin axis, construction axis, straight edge or sketch line ref; point: off-axis point ref; name?"),
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", crate::checked!("construct.plane.three_points", plane_points))
.at("SOLID", "CONSTRUCT")
.icon("plane_three_points")
@@ -374,6 +381,19 @@ fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) })
}
fn plane_axis_point(s: &mut Session, p: &Value) -> Result<Value> {
let kind = plane_axis_point_kind(s, p)?;
add_feature(s, p, kind)
}
/// Shared parser for the constructor and its world-to-authoring-frame boundary.
pub(crate) fn plane_axis_point_kind(s: &Session, p: &Value) -> Result<FeatureKind> {
let cmd = "construct.plane.axis_point";
let axis = geo(s, p.get("axis"), cmd, "axis")?;
let point = geo(s, p.get("point"), cmd, "point")?;
Ok(FeatureKind::ConstructionPlane { plane: PlaneRef::AxisPoint { axis: Box::new(axis), point: Box::new(point) } })
}
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.three_points";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
+1 -1
View File
@@ -37,7 +37,7 @@ mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis};
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, plane_axis_point_kind};
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
+82
View File
@@ -108,6 +108,88 @@ fn on_plane(pl: &Plane, p: Vec3) -> bool {
(p - pl.origin).dot(pl.normal()).abs() < 1e-3
}
#[test]
fn plane_through_axis_and_point_contains_both_and_rejects_an_on_axis_point() {
let mut s = Session::default();
run(&mut s, "construct.plane.axis_point", json!({"axis": {"axis": "Z"}, "point": {"point": [10, 20, 30]}, "name": "Fence"}));
let pl = plane(&s, "Fence");
for p in [Vec3::ZERO, Vec3::Z * 100.0, Vec3::new(10.0, 20.0, 30.0)] {
assert!(on_plane(&pl, p), "{p:?} is on {pl:?}");
}
assert!(parallel(pl.normal(), Vec3::new(-20.0, 10.0, 0.0)));
let before = s.doc.to_json();
let error = s.execute("construct.plane.axis_point", &json!({"axis": {"axis": "Z"}, "point": {"point": [0, 0, 50]}})).unwrap_err();
assert!(error.to_string().contains("point is on the axis"), "{error}");
assert_eq!(s.doc.to_json(), before, "a degenerate plane leaves the design unchanged");
}
#[test]
fn plane_through_axis_and_point_follows_renamed_sources_edits_undo_and_reopen() {
let mut s = Session::default();
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[5,6,7]},"b":{"point":[5,6,17]},"name":"Spine"}));
run(&mut s, "construct.point.vertex", json!({"point":{"point":[15,6,7]},"name":"Pin"}));
run(&mut s, "construct.plane.axis_point", json!({"axis":{"axis":"Spine"},"point":{"construct_point":"Pin"},"name":"Fence"}));
assert!(parallel(plane(&s, "Fence").normal(), Vec3::Y));
run(&mut s, "timeline.rename", json!({"feature":"Spine","name":"Mast"}));
run(&mut s, "timeline.rename", json!({"feature":"Pin","name":"Peg"}));
let definition = run(&mut s, "timeline.inspect", json!({"feature":"Fence"}));
assert_eq!(definition["definition"]["plane"]["axis"]["name"], "Mast");
assert_eq!(definition["definition"]["plane"]["point"]["name"], "Peg");
run(&mut s, "timeline.edit", json!({"feature":"Peg","set":{"def.point":{"type":"point","p":[5,16,7]}}}));
assert!(parallel(plane(&s, "Fence").normal(), Vec3::X), "point edit rotates the dependent plane");
run(&mut s, "edit.undo", json!({}));
assert!(parallel(plane(&s, "Fence").normal(), Vec3::Y));
run(&mut s, "edit.redo", json!({}));
let reopened = solidcraft_doc::format::read(&s.doc.to_json()).unwrap();
let mut cold = Session::new(reopened);
cold.refresh();
assert!(cold.model.results.iter().all(|r| r.error.is_none()), "{:?}", cold.model.results);
let pl = plane(&cold, "Fence");
assert!(parallel(pl.normal(), Vec3::X));
assert!(on_plane(&pl, Vec3::new(5.0, 16.0, 7.0)));
run(&mut cold, "sketch.create", json!({"plane":"Fence"}));
}
#[test]
fn plane_through_axis_and_point_accepts_straight_model_and_sketch_lines() {
let mut s = part();
run(&mut s, "construct.plane.axis_point", json!({"axis":{"edge":[20,0,0]},"point":{"vertex":[40,30,0]},"name":"Base"}));
assert!(parallel(plane(&s, "Base").normal(), Vec3::Z));
let sk = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
let line = run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
let curve = line["curves"][0].as_str().unwrap();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.axis_point", json!({"axis":{"sketch":sk,"curve":curve},"point":{"point":[0,0,10]},"name":"Side"}));
assert!(parallel(plane(&s, "Side").normal(), Vec3::Y));
}
#[test]
fn plane_through_axis_and_point_in_a_moved_component_keeps_its_world_picks() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,0,0]}));
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[50,0,0]},"b":{"point":[50,0,10]},"name":"Mast"}));
run(&mut s, "construct.plane.axis_point", json!({"axis":{"axis":"Mast"},"point":{"point":[60,0,0]},"name":"Fence"}));
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[0,20,0]}));
let pl = plane(&s, "Fence");
assert!(parallel(pl.normal(), Vec3::Y));
for p in [Vec3::new(50.0, 20.0, 0.0), Vec3::new(50.0, 20.0, 10.0), Vec3::new(60.0, 20.0, 0.0)] {
assert!(on_plane(&pl, p), "{p:?} on {pl:?}");
}
run(&mut s, "component.activate", json!({"component":0}));
run(
&mut s,
"timeline.redefine",
json!({"feature":"Fence","command":"construct.plane.axis_point","params":{"axis":{"axis":"Mast"},"point":{"point":[70,20,0]}}}),
);
let definition = run(&mut s, "timeline.inspect", json!({"feature":"Fence"}));
assert_eq!(
definition["definition"]["plane"]["point"],
json!({"type":"point","p":[20.,0.,0.]}),
"redefine uses the feature's authoring frame, not the active root"
);
}
#[test]
fn axis_through_cylinder_cone_or_torus() {
let mut s = part();
+15 -1
View File
@@ -404,7 +404,7 @@ impl Nested {
});
if outer {
let inner = if id == "timeline.redefine" { None } else { Some(id) };
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty())));
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty() || i == "construct.plane.axis_point")));
}
(Nested, outer)
}
@@ -487,6 +487,20 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
{
return None;
}
if id == "construct.plane.axis_point" && placed(s) {
// Construction references include tagged saved forms as well as pick wrappers.
// Map the parsed definition once at the boundary, including a nested Edit Feature.
// The constructor can then name local model picks and retain their exact references.
let mut kind = crate::cmd::plane_axis_point_kind(s, params).ok()?;
kind.transform_geometry(&mat_inverse(&s.active_component_frame())?);
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AxisPoint { axis, point } } = kind else {
return None;
};
let mut out = params.clone();
out["axis"] = serde_json::to_value(axis).ok()?;
out["point"] = serde_json::to_value(point).ok()?;
return Some(out);
}
let keys = table(id);
if keys.is_empty() || !placed(s) {
return None;
+12
View File
@@ -175,6 +175,18 @@ pub struct PlaneTwoEdgesParams {
pub common: PlaneCommon,
}
/// Add a construction plane containing an axis and a point off that axis.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PlaneAxisPointParams {
/// An origin axis, construction axis, straight edge or sketch line reference.
pub axis: GeoRefParam,
/// A vertex, sketch point, construction point or fixed point, not on the axis.
pub point: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// Add a construction plane through three points.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
+4 -2
View File
@@ -2,8 +2,8 @@ use serde_json::{Value, json};
use super::{
AxisEdgeParams, AxisNormalToFaceParams, AxisThroughCylinderParams, AxisTwoPlanesParams, AxisTwoPointsParams, MidplaneParams,
PlaneAlongPathParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams, PointAlongPathParams,
PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams,
PlaneAlongPathParams, PlaneAxisPointParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams,
PointAlongPathParams, PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams,
};
use crate::Session;
use crate::schema::features::FeatureResult;
@@ -24,6 +24,7 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<PointEdgePlaneParams>(), &["edge", "name", "participants", "plane"]);
assert_keys(input_schema_for::<PointAlongPathParams>(), &["name", "participants", "path", "t"]);
assert_keys(input_schema_for::<MidplaneParams>(), &["a", "b", "name", "participants"]);
assert_keys(input_schema_for::<PlaneAxisPointParams>(), &["axis", "name", "participants", "point"]);
assert_keys(input_schema_for::<PlaneTwoEdgesParams>(), &["a", "b", "name", "participants"]);
assert_keys(input_schema_for::<PlaneThreePointsParams>(), &["a", "b", "c", "name", "participants"]);
assert_keys(input_schema_for::<PlaneTangentParams>(), &["at", "face", "name", "participants"]);
@@ -88,6 +89,7 @@ fn construction_features_reply_with_feature_result() {
run("construct.axis.two_points", json!({"a": {"type": "point", "p": [0, 0, 0]}, "b": {"point": {"x": 1, "y": 2, "z": 3}}})),
run("construct.axis.edge", json!({"edge": {"edge": [5, 0, 0]}, "participants": []})),
run("construct.plane.midplane", json!({"a": "XY", "b": {"face": [5, 5, 10]}, "name": "Middle"})),
run("construct.plane.axis_point", json!({"axis":{"axis":"Z"},"point":{"point":[10,20,30]}})),
run("construct.plane.two_edges", json!({"a": {"edge": [5, 0, 0]}, "b": {"edge": [0, 5, 0]}})),
run("construct.plane.three_points", json!({"a": {"point": [0, 0, 0]}, "b": {"vertex": [10, 0, 0]}, "c": {"sketch": sketch, "point": point}})),
run("construct.plane.tangent", json!({"face": {"face": [35, 0, 5]}})),
+1
View File
@@ -33,6 +33,7 @@ pub const DOCUMENTED: &[&str] = &[
"construct.axis.two_points",
"construct.plane.along_path",
"construct.plane.at_angle",
"construct.plane.axis_point",
"construct.plane.midplane",
"construct.plane.offset",
"construct.plane.perpendicular",
+47 -5
View File
@@ -1660,7 +1660,7 @@ impl Dialog {
if self.take_extrude_target(s, &sel) {
return;
}
if let Some(key) = feature_pattern_reference_key(self, self.active) {
if let Some(key) = stored_input_reference_key(self, self.active) {
self.extra.remove(key);
}
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
@@ -1734,6 +1734,9 @@ impl Dialog {
/// Box selection result: replace (or add to) the active input.
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
if let Some(key) = stored_input_reference_key(self, self.active) {
self.extra.remove(key);
}
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
*sketch = None;
self.extra.remove("path");
@@ -2654,7 +2657,10 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, names: crate::pref
}
/// Saved vectors/axes are real inputs even when they have no origin-axis selection chip.
fn feature_pattern_reference_key(d: &Dialog, index: usize) -> Option<&'static str> {
fn stored_input_reference_key(d: &Dialog, index: usize) -> Option<&'static str> {
if let Kind::Construct { command: "construct.plane.axis_point", keys, .. } = &d.kind {
return keys.get(index).copied();
}
if matches!(d.kind, Kind::Section { .. }) && index == 0 {
return Some("plane");
}
@@ -2698,7 +2704,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
let text_focus = crate::ui_kit::editor_has_focus(ui.ctx());
let shell_count = d.shell_faces(s).map(|faces| faces.len());
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
let stored_reference = feature_pattern_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty());
let stored_reference = stored_input_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty());
form_row(ui, inp.label, |ui| {
ui.horizontal(|ui| {
let active = i == d.active && !text_focus;
@@ -2786,7 +2792,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
d.active = i;
}
if let Some(i) = clear {
if let Some(key) = feature_pattern_reference_key(d, i) {
if let Some(key) = stored_input_reference_key(d, i) {
d.extra.remove(key);
}
let Some(inp) = d.inputs.get_mut(i) else { return };
@@ -4655,7 +4661,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
for (i, key) in keys.iter().enumerate() {
let input = d.inputs.get(i).map_or("", |inp| inp.label);
let sel = sels(d, i).first().ok_or_else(|| format!("pick the {}", input.to_lowercase()))?;
p.insert((*key).to_string(), construct_ref(app, sel)?);
p.insert(
(*key).to_string(),
match d.extra.get(*key) {
Some(saved) => saved.clone(),
None => construct_ref(app, sel)?,
},
);
}
if let Some(t) = t {
p.insert("t".into(), json!(t));
@@ -5537,6 +5549,9 @@ fn construct_picks(id: &str) -> Option<(&'static str, &'static str, &'static [(&
("construct.point.edge_plane", "POINT AT EDGE AND PLANE", &[("Edge", CURVE, "edge"), ("Plane", PLANE, "plane")])
}
"construct.point.along_path" => ("construct.point.along_path", "POINT ALONG PATH", &[("Path", CURVE, "path")]),
"construct.plane.axis_point" => {
("construct.plane.axis_point", "cmd.construct.plane.axis_point", &[("Axis", LINE | CURVES, "axis"), ("Point", POINT, "point")])
}
"construct.plane.two_edges" => {
("construct.plane.two_edges", "PLANE THROUGH TWO EDGES", &[("First edge", LINE, "a"), ("Second edge", LINE, "b")])
}
@@ -6376,6 +6391,32 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::ConstructionPlane { plane: PlaneRef::AxisPoint { .. } } => {
let mut world = f.kind.clone();
world.transform_geometry(&feature_frame(s, f.component));
let FeatureKind::ConstructionPlane { plane: PlaneRef::AxisPoint { axis, point } } = world else { return None };
let mut d = start("construct.plane.axis_point")?;
for (input, reference) in d.inputs.iter_mut().zip([&axis, &point]) {
// Keep the exact saved references when no new pick has replaced them.
// Fixed points also need a visible pick to keep the shared form complete.
input.items = vec![match reference.as_ref() {
solidcraft_engine::doc::construct::GeoRef::World { axis } => Sel::Axis { name: axis.clone() },
solidcraft_engine::doc::construct::GeoRef::Axis { name } => Sel::Axis { name: name.to_string() },
solidcraft_engine::doc::construct::GeoRef::ConstructPoint { name } => Sel::ConstructionPoint { name: name.to_string() },
solidcraft_engine::doc::construct::GeoRef::SketchPoint { sketch, point } => Sel::SketchPoint { id: format!("{sketch}:{point}") },
solidcraft_engine::doc::construct::GeoRef::SketchCurve { sketch, curve } => {
Sel::SketchCurve { id: curve.clone(), sketch: Some(*sketch) }
}
solidcraft_engine::doc::construct::GeoRef::Vertex { at, .. } => Sel::Vertex { body: String::new(), point: *at },
solidcraft_engine::doc::construct::GeoRef::Point { p } => Sel::Vertex { body: String::new(), point: *p },
solidcraft_engine::doc::construct::GeoRef::Origin => Sel::Vertex { body: String::new(), point: Vec3::ZERO },
other => model_axis_sel(s, other)?,
}];
}
d.extra.insert("axis".into(), serde_json::to_value(axis).ok()?);
d.extra.insert("point".into(), serde_json::to_value(point).ok()?);
d
}
FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a, b } } => {
let mut d = start("construct.plane.midplane")?;
if let Some(inp) = d.inputs.first_mut() {
@@ -7132,6 +7173,7 @@ mod construct_ask_tests {
"construct.point.center",
"construct.point.edge_plane",
"construct.point.along_path",
"construct.plane.axis_point",
"construct.plane.two_edges",
"construct.plane.three_points",
"construct.plane.tangent",
+2 -1
View File
@@ -928,7 +928,8 @@ mod tests {
}
let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect();
let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect();
assert_eq!(construction.len(), 19);
assert_eq!(construction.len(), 20);
assert_eq!(construction.iter().find(|c| c.id == "construct.plane.axis_point").unwrap().icon, "plane_axis_point");
assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities");
}
+97
View File
@@ -2551,6 +2551,8 @@ fn offset_plane_through_a_picked_vertex() {
assert_eq!(commands.first().map(|c| c.1.clone()), Some(json!({"base":"XY","through":{"vertex":[40.0,30.0,20.0]},"offset":null})));
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
h.frames(3);
axis_point_evidence(&mut h, "axis-point-result");
let (id, _, pl) = solidcraft_engine::view::construction_planes(&h.app.session).into_iter().next().expect("the plane");
assert!(pl.normal().cross(Vec3::Z).len() < 1e-6 && (pl.origin.z - 20.0).abs() < 1e-6, "{pl:?}");
let reopened = crate::dialogs::for_feature(&h.app, id, None).unwrap();
@@ -2669,6 +2671,101 @@ fn plane_through_two_picked_edges() {
assert!(pl.normal().cross(Vec3::Z).len() < 1e-6 && (pl.origin.z - 20.0).abs() < 1e-6, "the top face's plane: {pl:?}");
}
fn axis_point_evidence(h: &mut crate::scenario::Harness, name: &str) {
if let Ok(dir) = std::env::var("SOLIDCRAFT_AXIS_POINT_EVIDENCE") {
// Real egui output, with the font/icon texture deltas retained for the rasteriser.
h.ctx = egui::Context::default();
crate::fonts::setup_bundled_fonts(&h.ctx);
let mut textures = std::collections::HashMap::new();
let mut output = None;
for frame in 0..12 {
let mut drawn = h.ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600., 1000.))),
time: Some(frame as f64 / 60.),
..Default::default()
},
|ui| h.app.ui(ui),
);
crate::ui_kit::test_textures_update(&mut textures, &drawn.textures_delta);
drawn.textures_delta.clear();
output = Some(drawn);
}
let output = output.unwrap();
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600., 1000.));
let image = crate::ui_kit::test_raster(&h.ctx, output.shapes, output.pixels_per_point, bounds, &textures);
std::fs::create_dir_all(&dir).unwrap();
image.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
}
/// An axis and an off-axis point fill the form and stay associative through an unchanged edit.
#[test]
fn plane_through_axis_and_point_preselects_creates_and_reopens_exact_references() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("construct.point.vertex", json!({"point":{"point":[20,10,0]},"name":"Peg"})).unwrap();
h.app.session.selection = vec![Sel::Axis { name: "Z".into() }, Sel::ConstructionPoint { name: "Peg".into() }];
h.app.ui.tab = "MODEL".into();
h.frames(4);
h.step(&json!({"click":{"handle":"panel:MODEL:CONSTRUCT"}})).unwrap();
h.frames(3);
h.step(&json!({"click":{"handle":"dropdown:construct.plane.axis_point"}})).unwrap();
h.frames(2);
let d = h.app.dialog.as_ref().expect("axis-and-point form");
assert_eq!((d.inputs[0].items.len(), d.inputs[1].items.len()), (1, 1));
assert_eq!((d.inputs[0].label, d.inputs[1].label), ("Axis", "Point"));
axis_point_evidence(&mut h, "axis-point-dialog");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
h.frames(3);
axis_point_evidence(&mut h, "axis-point-result");
let (id, _, pl) = solidcraft_engine::view::construction_planes(&h.app.session).into_iter().next().unwrap();
assert!(pl.normal().dot(Vec3::Z).abs() < 1e-6);
assert!((Vec3::new(20., 10., 0.) - pl.origin).dot(pl.normal()).abs() < 1e-6);
let before = h.app.session.doc.to_json();
h.app.edit_feature(id);
h.frames(2);
assert!(h.app.dialog.is_some(), "construction plane can be edited");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none());
assert_eq!(h.app.session.doc.to_json(), before, "reopening and OK preserves saved references");
}
#[test]
fn plane_through_axis_and_point_edit_replaces_only_the_picked_saved_reference() {
let mut h = crate::scenario::Harness::new();
let made = h.app.run("construct.plane.axis_point", json!({"axis":{"axis":"Z"},"point":{"point":[10,0,0]},"name":"Fence"})).unwrap();
let id = made["feature"].as_u64().unwrap();
h.app.edit_feature(id);
h.frames(1);
{
let d = h.app.dialog.as_mut().unwrap();
d.active = 0;
d.pick(&h.app.session, Sel::Axis { name: "Y".into() });
}
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
let (_, _, pl) = solidcraft_engine::view::construction_planes(&h.app.session).into_iter().next().unwrap();
assert!(pl.normal().cross(Vec3::Z).len() < 1e-6, "Y and fixed [10,0,0] define XY: {pl:?}");
let definition = h.app.run("timeline.inspect", json!({"feature":"Fence"})).unwrap();
assert_eq!(definition["definition"]["plane"]["point"], json!({"type":"point","p":[10.,0.,0.]}));
}
#[test]
fn plane_through_axis_and_point_edit_in_a_moved_component_keeps_its_fixed_point() {
let mut h = crate::scenario::Harness::new();
h.app.run("component.create", json!({"name":"Part","grounded":false})).unwrap();
h.app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[50,20,0]})).unwrap();
let made = h.app.run("construct.plane.axis_point", json!({"axis":{"axis":"Z"},"point":{"point":[60,20,0]},"name":"Fence"})).unwrap();
let before = h.app.session.doc.to_json();
h.app.edit_feature(made["feature"].as_u64().unwrap());
h.frames(1);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
assert_eq!(h.app.session.doc.to_json(), before, "editing maps saved local references through world and back once");
}
/// Point Along Path asks for its position too, and a missing pick names the input.
#[test]
fn point_along_path_has_its_position_and_missing_picks_are_named() {
+2
View File
@@ -200,6 +200,7 @@ fn construction_plane_icon(plane: &PlaneRef) -> &'static str {
PlaneRef::AtAngle { .. } => "plane_angle",
PlaneRef::Midplane { .. } => "plane_midplane",
PlaneRef::TwoEdges { .. } => "plane_two_edges",
PlaneRef::AxisPoint { .. } => "plane_axis_point",
PlaneRef::ThreePoints { .. } => "plane_three_points",
PlaneRef::Tangent { .. } => "plane_tangent",
PlaneRef::AlongPath { .. } => "plane_along_path",
@@ -1325,6 +1326,7 @@ mod tests {
}
for (id, plane) in [
("midplane", PlaneRef::Midplane { a: reference(), b: reference() }),
("axis_point", PlaneRef::AxisPoint { axis: reference(), point: reference() }),
("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }),
("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }),
("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }),
+11
View File
@@ -32,6 +32,17 @@ for the origin plane, the construction feature's numeric ID to select it, or an
`{origin: [x,y,z], normal: [x,y,z]}` plane. Existing saved references retain their meaning;
opening a design does not reinterpret old sketches.
**Plane Through Axis And Point** (Model > Construct) creates a plane containing an
origin or construction axis, straight model edge or sketch line, and a point off that
line. Pick the axis first, then a vertex, sketch point or construction point. A point
on the axis leaves infinitely many possible planes, so choose an off-axis point.
The plane follows its references when they change and can support a new sketch.
Scripts use `construct.plane.axis_point`, for example:
```json
{"axis":{"axis":"Z"},"point":{"point":[10,20,30]},"name":"Fence"}
```
Curves adapt to the current zoom and display scale. Picking and highlighting use the same
visible boundary, including small circles at Fit and sketches in rotated components.
Profile shading follows those curves while preserving holes. Zooming changes the display