Typed contracts for the construction axes, points and planes (#2829) (#7750)

* claim: #2829 (construct.* contracts)

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

* Typed contracts for the construction axes, points and planes

The 19 construct.* commands get input contracts and FeatureResult as
their output, with shared types for references, planes, a plane or
face base, an axis line and a construction plane's name, each calling
the reader its handler uses (split out of geo, plane_param, the face
capture and named_axis_line).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew Knutson
2026-10-30 06:03:04 +00:00
committed by GitHub
parent 4d338ba2eb
commit 991082fbaf
15 changed files with 1234 additions and 134 deletions
+4 -1
View File
@@ -344,10 +344,13 @@ fn model_line(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad("pick a straight or circular edge, or a cylinder, cone or torus face")),
_ => Err(bad(MODEL_AXIS_REFS)),
}
}
/// What [`model_axis`] says about a reference that is neither an edge nor a face.
pub const MODEL_AXIS_REFS: &str = "pick a straight or circular edge, or a cylinder, cone or torus face";
/// The axis a feature turns about when an edge or face of the model is picked for it (a revolve,
/// a circular pattern, a plane at an angle), as [`model_line`] finds it. The direction is
/// oriented the same way however the edge or surface runs (its largest component positive), so
+190 -58
View File
@@ -14,135 +14,267 @@ use solidcraft_geom::Vec3;
use super::CommandSpec;
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,
};
use crate::schema::features::FeatureResult;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const REF: &str =
pub(crate) const REF: &str =
"{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}";
const AXIS_THROUGH_CYLINDER_CONTRACT: TypedContract<AxisThroughCylinderParams> = TypedContract::new();
const AXIS_NORMAL_TO_FACE_CONTRACT: TypedContract<AxisNormalToFaceParams> = TypedContract::new();
const AXIS_TWO_PLANES_CONTRACT: TypedContract<AxisTwoPlanesParams> = TypedContract::new();
const AXIS_TWO_POINTS_CONTRACT: TypedContract<AxisTwoPointsParams> = TypedContract::new();
const AXIS_EDGE_CONTRACT: TypedContract<AxisEdgeParams> = TypedContract::new();
const POINT_VERTEX_CONTRACT: TypedContract<PointVertexParams> = TypedContract::new();
const POINT_TWO_EDGES_CONTRACT: TypedContract<PointTwoEdgesParams> = TypedContract::new();
const POINT_THREE_PLANES_CONTRACT: TypedContract<PointThreePlanesParams> = TypedContract::new();
const POINT_CENTER_CONTRACT: TypedContract<PointCenterParams> = TypedContract::new();
const POINT_EDGE_PLANE_CONTRACT: TypedContract<PointEdgePlaneParams> = TypedContract::new();
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_THREE_POINTS_CONTRACT: TypedContract<PlaneThreePointsParams> = TypedContract::new();
const PLANE_TANGENT_CONTRACT: TypedContract<PlaneTangentParams> = TypedContract::new();
const PLANE_ALONG_PATH_CONTRACT: TypedContract<PlaneAlongPathParams> = TypedContract::new();
const PLANE_PERPENDICULAR_CONTRACT: TypedContract<PlanePerpendicularParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face)
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", crate::checked!("construct.axis.through_cylinder", axis_face))
.at("SOLID", "CONSTRUCT")
.icon("axis_through_cylinder")
.contract(&AXIS_THROUGH_CYLINDER_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"),
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal)
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", crate::checked!("construct.axis.normal_to_face", axis_normal))
.at("SOLID", "CONSTRUCT")
.icon("axis_normal_to_face")
.contract(&AXIS_NORMAL_TO_FACE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("face: ref; point: ref (the axis goes through it, square to the face); name?"),
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes)
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", crate::checked!("construct.axis.two_planes", axis_planes))
.at("SOLID", "CONSTRUCT")
.icon("axis_two_planes")
.contract(&AXIS_TWO_PLANES_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b: plane or planar face refs; name?"),
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points)
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", crate::checked!("construct.axis.two_points", axis_points))
.at("SOLID", "CONSTRUCT")
.icon("axis_two_points")
.contract(&AXIS_TWO_POINTS_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b: point refs (vertices, sketch points, points); name?"),
CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge)
CommandSpec::new("construct.axis.edge", "Axis Through Edge", crate::checked!("construct.axis.edge", axis_edge))
.at("SOLID", "CONSTRUCT")
.icon("axis_edge")
.contract(&AXIS_EDGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("edge: ref (a straight edge or sketch line); name?"),
CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at)
CommandSpec::new("construct.point.vertex", "Point At Vertex", crate::checked!("construct.point.vertex", point_at))
.at("SOLID", "CONSTRUCT")
.icon("point_vertex")
.contract(&POINT_VERTEX_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("point: ref (vertex, sketch point, point); name?"),
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges)
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", crate::checked!("construct.point.two_edges", point_edges))
.at("SOLID", "CONSTRUCT")
.icon("point_two_edges")
.contract(&POINT_TWO_EDGES_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b: edge or line refs (where they meet, or come closest); name?"),
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes)
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", crate::checked!("construct.point.three_planes", point_planes))
.at("SOLID", "CONSTRUCT")
.icon("point_three_planes")
.contract(&POINT_THREE_PLANES_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b, c: plane or planar face refs; name?"),
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center)
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", crate::checked!("construct.point.center", point_center))
.at("SOLID", "CONSTRUCT")
.icon("point_center")
.contract(&POINT_CENTER_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"),
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane)
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", crate::checked!("construct.point.edge_plane", point_edge_plane))
.at("SOLID", "CONSTRUCT")
.icon("point_edge_plane")
.contract(&POINT_EDGE_PLANE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("edge: edge or line ref; plane: plane or planar face ref; name?"),
CommandSpec::new("construct.point.along_path", "Point Along Path", point_along)
CommandSpec::new("construct.point.along_path", "Point Along Path", crate::checked!("construct.point.along_path", point_along))
.at("SOLID", "CONSTRUCT")
.icon("point_along_path")
.contract(&POINT_ALONG_PATH_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"),
CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid)
CommandSpec::new("construct.plane.midplane", "Midplane", crate::checked!("construct.plane.midplane", plane_mid))
.at("SOLID", "CONSTRUCT")
.icon("plane_midplane")
.contract(&PLANE_MIDPLANE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"),
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges)
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", crate::checked!("construct.plane.two_edges", plane_edges))
.at("SOLID", "CONSTRUCT")
.icon("plane_two_edges")
.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.three_points", "Plane Through Three Points", plane_points)
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")
.contract(&PLANE_THREE_POINTS_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("a, b, c: point refs; name?"),
CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent)
CommandSpec::new("construct.plane.tangent", "Tangent Plane", crate::checked!("construct.plane.tangent", plane_tangent))
.at("SOLID", "CONSTRUCT")
.icon("plane_tangent")
.contract(&PLANE_TANGENT_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"),
CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along)
CommandSpec::new("construct.plane.along_path", "Plane Along Path", crate::checked!("construct.plane.along_path", plane_along))
.at("SOLID", "CONSTRUCT")
.icon("plane_along_path")
.contract(&PLANE_ALONG_PATH_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane_perpendicular").params(
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
),
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", crate::checked!("construct.plane.perpendicular", plane_perpendicular))
.at("SOLID", "CONSTRUCT")
.icon("plane_perpendicular")
.contract(&PLANE_PERPENDICULAR_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
),
];
/// Whether [`model_axis_param`] reads `v` as an edge or face of the model: `{edge: …}`,
/// `{face: …}`, or as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it
/// kept.
pub(crate) fn is_model_axis(v: &Value) -> bool {
v.get("edge").is_some() || v.get("face").is_some() || matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face"))
}
/// An axis on an edge or round face of the model (`{edge: [x,y,z]}` or `{face: [x,y,z]}`, the
/// point on it), with the line it gives now (`solidcraft_doc::construct::model_axis`); `None`
/// for any other form of `axis`.
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
// Also as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it kept.
let saved = |v: &Value| matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face"));
let Some(v) = v.filter(|v| v.get("edge").is_some() || v.get("face").is_some() || saved(v)) else { return Ok(None) };
let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) };
let g = geo(s, Some(v), cmd, "axis")?;
let (o, d) = solidcraft_doc::construct::model_axis(&s.world_state(), &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
Ok(Some((g, o, d)))
}
/// A reference as [`geo`] reads it before looking anything up in the design: which form it has.
pub(crate) enum GeoForm<'a> {
/// As saved (`{type, …}`), read whole.
Saved(GeoRef),
Face(Vec3),
Edge(Vec3),
Vertex(Vec3),
/// `{sketch, point|curve}`: the sketch as given, and the point or curve, when one is given.
Sketch {
sketch: &'a Value,
part: Option<SketchPart<'a>>,
},
Point(Vec3),
/// `{plane: …}`'s value, or a bare string: a plane, as `plane_param` reads it.
Plane(&'a Value),
/// `{axis: X|Y|Z|name}`.
Axis(&'a str),
/// `{construct_point: name}`.
ConstructPoint(&'a str),
}
/// A sketch reference's point or curve id.
pub(crate) enum SketchPart<'a> {
Point(&'a str),
Curve(&'a str),
}
/// Why [`geo_form`] refuses a reference.
pub(crate) enum GeoFormError {
/// A saved form serde refuses, with its message.
Saved(String),
/// None of the forms.
Unknown,
}
/// What [`geo`] says when a sketch reference has neither `point` nor `curve`.
pub(crate) const SKETCH_PART: &str = "a sketch reference needs `point` or `curve`";
/// The form [`geo`] reads `v` as. The first that fits wins, in this order: `type` (read whole), a
/// point at `face`, `edge` or `vertex`, `sketch` (whatever else it has), a point at `point`,
/// `plane` (whatever it holds), `axis` and `construct_point` as text, and a bare string.
pub(crate) fn geo_form(v: &Value) -> std::result::Result<GeoForm<'_>, GeoFormError> {
let p3 = |k: &str| v.get(k).and_then(vec3);
Ok(if v.get("type").is_some() {
GeoForm::Saved(serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| GeoFormError::Saved(e.to_string()))?)
} else if let Some(at) = p3("face") {
GeoForm::Face(at)
} else if let Some(at) = p3("edge") {
GeoForm::Edge(at)
} else if let Some(at) = p3("vertex") {
GeoForm::Vertex(at)
} else if let Some(sketch) = v.get("sketch") {
let part = match (str_(v, "point"), str_(v, "curve")) {
(Some(point), _) => Some(SketchPart::Point(point)),
(None, Some(curve)) => Some(SketchPart::Curve(curve)),
_ => None,
};
GeoForm::Sketch { sketch, part }
} else if let Some(p) = p3("point") {
GeoForm::Point(p)
} else if let Some(plane) = v.get("plane") {
GeoForm::Plane(plane)
} else if let Some(a) = str_(v, "axis") {
GeoForm::Axis(a)
} else if let Some(n) = str_(v, "construct_point") {
GeoForm::ConstructPoint(n)
} else if v.is_string() {
GeoForm::Plane(v)
} else {
return Err(GeoFormError::Unknown);
})
}
/// A reference from its JSON form.
pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
let p3 = |k: &str| v.get(k).and_then(vec3);
let mut g = if v.get("type").is_some() {
serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))?
} else if let Some(at) = p3("face") {
GeoRef::Face { at, name: None }
} else if let Some(at) = p3("edge") {
GeoRef::Edge { at, name: None }
} else if let Some(at) = p3("vertex") {
GeoRef::Vertex { at, edges: None }
} else if let Some(sk) = v.get("sketch") {
let sketch = sketch_id(s, Some(sk), cmd, what)?;
match (str_(v, "point"), str_(v, "curve")) {
(Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() },
(None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
_ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))),
}
} else if let Some(p) = p3("point") {
GeoRef::Point { p }
} else if v.get("plane").is_some() {
GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? }
} else if let Some(a) = str_(v, "axis") {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
let mut g = match geo_form(v) {
Ok(GeoForm::Saved(g)) => g,
Ok(GeoForm::Face(at)) => GeoRef::Face { at, name: None },
Ok(GeoForm::Edge(at)) => GeoRef::Edge { at, name: None },
Ok(GeoForm::Vertex(at)) => GeoRef::Vertex { at, edges: None },
Ok(GeoForm::Sketch { sketch, part }) => {
let sketch = sketch_id(s, Some(sketch), cmd, what)?;
match part {
Some(SketchPart::Point(point)) => GeoRef::SketchPoint { sketch, point: point.to_string() },
Some(SketchPart::Curve(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
None => return Err(bad(cmd, format!("`{what}`: {SKETCH_PART}"))),
}
}
} else if let Some(n) = str_(v, "construct_point") {
match s.doc.find_feature(n).map(|f| &f.kind) {
Ok(GeoForm::Point(p)) => GeoRef::Point { p },
Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
Ok(GeoForm::Axis(a)) => {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
}
}
}
Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
}
} else if v.as_str().is_some() {
// A bare string: a plane.
GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? }
} else {
return Err(bad(cmd, format!("`{what}` must be {REF}")));
},
Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))),
Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))),
};
g.name_picks(&s.model.state());
Ok(g)
+48 -23
View File
@@ -12,7 +12,8 @@ use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
use crate::schema::features::{
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, PipeParams, ScaleParams, SphereParams, TorusParams,
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams,
ScaleParams, SphereParams, TorusParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -26,6 +27,8 @@ const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
@@ -137,13 +140,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z] points, or face names from model.faces]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
CommandSpec::new("construct.plane.offset", "Offset Plane", crate::checked!("construct.plane.offset", plane_offset))
.at("SOLID", "CONSTRUCT")
.icon("plane")
.contract(&OFFSET_PLANE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr, or through: a point ref (vertex, sketch point, construction point) the plane goes through, parallel to base; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", crate::checked!("construct.plane.at_angle", plane_angle))
.at("SOLID", "CONSTRUCT")
.icon("plane_angle")
.contract(&PLANE_AT_ANGLE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle: expr; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
@@ -1946,19 +1953,24 @@ fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Opt
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
},
Some(o @ Value::Object(_)) => {
let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(v) => vec3(v).ok_or_else(|| {
bad(cmd, "axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported")
})?,
};
let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?;
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
Ok((origin, dir, None))
}
_ => Err(bad(cmd, "`axis` is required")),
}
}
/// An axis line `{origin?, dir}` as [`named_axis_line`] reads it: the origin (default: the
/// origin) and the unit direction.
pub(crate) fn axis_line_object(o: &Value) -> std::result::Result<(Vec3, Vec3), &'static str> {
let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(v) => vec3(v).ok_or("axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported")?,
};
let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or("axis needs a non-zero `dir`")?;
Ok((origin, dir))
}
/// A pattern feature, or for `components`, new occurrences of them at each placement.
fn finish_pattern(
s: &mut Session,
@@ -2129,13 +2141,31 @@ pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> {
/// A construction plane may not take an origin plane's name (`XY`, `Top`...): a plane
/// parameter could then mean either.
pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> {
if solidcraft_geom::Plane::named(name.trim()).is_some() {
return Err(bad(
cmd,
format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim()),
));
match plane_name_problem(name) {
Some(why) => Err(bad(cmd, why)),
None => Ok(()),
}
}
/// What [`check_plane_name`] says about `name`, or `None` when a construction plane may take it.
pub(crate) fn plane_name_problem(name: &str) -> Option<String> {
solidcraft_geom::Plane::named(name.trim()).map(|_| {
format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim())
})
}
/// The forms [`plane_param`] takes, for its messages.
pub(crate) const PLANE_FORMS: &str =
"XY, XZ, YZ, a construction plane name or id, or {origin, normal} (origin coordinates may be length expressions)";
/// The plane an explicit plane object gives through `origin`, as [`plane_param`] reads it: from
/// `x_dir` and `y_dir` when both are points, else from `normal`; `None` when they don't make one.
pub(crate) fn explicit_plane(o: &Value, origin: Vec3) -> Option<solidcraft_geom::Plane> {
match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solidcraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solidcraft_geom::Plane::from_normal(origin, n),
_ => None,
}
Ok(())
}
/// A plane reference from a parameter: an origin plane, a construction plane (by name or
@@ -2144,7 +2174,7 @@ pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> {
/// plane reads it here.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solidcraft_doc::PlaneRef> {
use solidcraft_doc::PlaneRef;
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal} (origin coordinates may be length expressions)";
const WANT: &str = PLANE_FORMS;
let construction = |r: &str| match s.doc.find_feature(r) {
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { feature: f.id.into() }),
Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))),
@@ -2181,12 +2211,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
};
let at = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0);
let here = Vec3::new(at(&origin[0]), at(&origin[1]), at(&origin[2]));
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solidcraft_geom::Plane::new(here, x, y),
(_, _, Some(n)) => solidcraft_geom::Plane::from_normal(here, n),
_ => None,
};
let plane = pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))?;
let plane = explicit_plane(o, here).ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))?;
if origin.iter().all(|e| solidcraft_doc::expr::is_literal(e)) {
return Ok(PlaneRef::Custom { plane });
}
+3 -1
View File
@@ -36,12 +36,14 @@ 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 face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key};
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use join_pieces::{Addition, addition};
pub use sketch::dimension_roles;
pub(crate) use sketch::face::face_pick_form;
pub use sketch_construction::{ConstructionLine, construction_line_choice, construction_line_mode, construction_line_solutions};
pub use sketch_import::drawing_import_command;
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
+40 -20
View File
@@ -18,48 +18,68 @@ pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) ->
capture_impl(s, value, cmd, true)
}
fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result<PlaneRef> {
/// A planar face pick `{face, fixed?, body?, index?, path?}` as [`capture`] reads it before it
/// looks at the design.
pub(crate) struct FacePickForm<'a> {
/// The picked point, in world coordinates.
pub point: solidcraft_geom::Vec3,
/// Keep the plane as it is now instead of following the face.
pub fixed: bool,
/// The exact pick: the world body and the face's index, given together.
pub body: Option<&'a str>,
pub index: Option<usize>,
/// The occurrence path of the body's placement.
pub path: Option<Vec<u64>>,
}
/// What [`capture`] reads `value` as, or its message for the first key it refuses, in the order
/// `face`, `fixed`, `body`, `index`, the two together, then `path`.
pub(crate) fn face_pick_form(value: &Value) -> std::result::Result<FacePickForm<'_>, &'static str> {
let point = value
.get("face")
.and_then(vec3)
.filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
.ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?;
.ok_or("face point must be three finite world coordinates")?;
let fixed = match value.get("fixed") {
None => false,
Some(Value::Bool(fixed)) => *fixed,
_ => return Err(bad(cmd, "fixed must be a boolean")),
_ => return Err("fixed must be a boolean"),
};
let state = s.model.state();
let doc = s.placed_document();
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?;
let requested_body = match value.get("body") {
let body = match value.get("body") {
None => None,
Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()),
_ => return Err(bad(cmd, "body must be the picked world body name")),
_ => return Err("body must be the picked world body name"),
};
let requested_index = match value.get("index") {
let index = match value.get("index") {
None => None,
Some(value) => {
Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?)
}
Some(value) => Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or("index must be a nonnegative face index")?),
};
if requested_body.is_some() != requested_index.is_some() {
return Err(bad(cmd, "an exact face pick requires both body and index"));
if body.is_some() != index.is_some() {
return Err("an exact face pick requires both body and index");
}
let requested_path = match value.get("path") {
let path = match value.get("path") {
None => None,
Some(value) => Some(
value
.as_array()
.filter(|path| path.len() <= 64)
.ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))?
.ok_or("path must contain at most64 occurrence ids")?
.iter()
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers")))
.collect::<Result<Vec<_>>>()?,
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or("path ids must be nonzero integers"))
.collect::<std::result::Result<Vec<_>, _>>()?,
),
};
Ok(FacePickForm { point, fixed, body, index, path })
}
fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result<PlaneRef> {
let FacePickForm { point, fixed, body: requested_body, index: requested_index, path: requested_path } =
face_pick_form(value).map_err(|why| bad(cmd, why))?;
let state = s.model.state();
let doc = s.placed_document();
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?;
let selected_path = requested_path
.clone()
.or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body))));
+13
View File
@@ -129,6 +129,16 @@ pub(crate) fn whole(message: impl std::fmt::Display) -> String {
format!("{WHOLE}{message}")
}
/// Marks a deserializer's message that the handler gives after the key and a colon, as it is
/// (serde's own wording for a saved reference, such as "missing field `at`", which
/// [`parse_issue`] would otherwise reword).
const KEYED: char = '\u{3}';
/// `message`, to follow the key and a colon as it is (see [`KEYED`]).
pub(crate) fn keyed(message: impl std::fmt::Display) -> String {
format!("{KEYED}{message}")
}
/// Marks a deserializer's message that carries its own path: an error from a flattened
/// `FeatureCommon`, which serde can't locate. The path (one of its field names) and the error
/// follow as JSON.
@@ -233,6 +243,9 @@ pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) ->
Some((field, inner)) => (format!("{path}.{field}"), inner),
None => (path, inner),
};
if let Some(after) = inner.strip_prefix(KEYED) {
return Issue { path: path.clone(), message: truncate(&format!("`{path}`: {after}"), MAX_WHOLE_LEN), suggestion: None };
}
if let Some(complete) = inner.strip_prefix(WHOLE) {
// The handler's own message, which names the caller's value: in full up to a generous
// cap, since the contract also checks keys the handler ignores (`side` without `thin`).
+239
View File
@@ -0,0 +1,239 @@
//! Contracts for the commands in `cmd/construct.rs`: construction axes, points and planes built
//! from model geometry. Their reply is a [`super::features::FeatureResult`].
//!
//! Every reference is a [`GeoRefParam`]; its description says the forms, and each key's says
//! what the evaluation needs it to be (a plane, a line, a point…). A reference of the wrong kind
//! is refused when the feature is evaluated, which fails the command.
use schemars::JsonSchema;
use serde::Deserialize;
use super::types::{ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present};
/// Add a construction axis along the axis of a cylinder, cone or torus face, or of a circular
/// edge.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AxisThroughCylinderParams {
/// The face or circular edge whose axis it is. Any line works: a straight edge or sketch
/// line, X, Y, Z or a construction axis gives itself.
pub face: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction axis square to a face, through a point.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AxisNormalToFaceParams {
/// The face: a plane, or a planar, cylindrical, conical, spherical or toroidal face.
pub face: GeoRefParam,
/// The point the axis goes through, square to the face where the face is nearest it: a
/// vertex, a construction point, a sketch point, a fixed point, or the centre of a circular
/// edge, a sketch circle or arc, or a spherical or toroidal face. Not on the axis of a round
/// face.
pub point: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction axis where two planes meet.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AxisTwoPlanesParams {
/// The first plane or planar face.
pub a: GeoRefParam,
/// The second plane or planar face, not parallel to the first.
pub b: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction axis through two points, from `a` towards `b`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AxisTwoPointsParams {
/// The first point: a vertex, a construction point, a sketch point, a fixed point, or the
/// centre of a circular edge, a sketch circle or arc, or a spherical or toroidal face.
pub a: GeoRefParam,
/// The second point, somewhere else.
pub b: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction axis along a straight edge or line.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AxisEdgeParams {
/// The line: a straight edge, a sketch line, X, Y, Z or a construction axis (a cylinder, cone
/// or torus face gives its axis).
pub edge: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction point at a vertex or point.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PointVertexParams {
/// The point: a vertex, a construction point, a sketch point, a fixed point, or the centre of
/// a circular edge, a sketch circle or arc, or a spherical or toroidal face.
pub point: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction point where two lines meet, or midway between their closest points.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PointTwoEdgesParams {
/// The first line: a straight edge, a sketch line, X, Y, Z or a construction axis (a circular
/// edge or a cylinder, cone or torus face gives its axis).
pub a: GeoRefParam,
/// The second line, not parallel to the first.
pub b: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction point where three planes meet.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PointThreePlanesParams {
/// The first plane or planar face.
pub a: GeoRefParam,
/// The second plane or planar face, not parallel to the first.
pub b: GeoRefParam,
/// The third plane or planar face, not parallel to the line where the first two meet.
pub c: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction point at the centre of a circle, sphere or torus.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PointCenterParams {
/// A circular edge, a sketch circle or arc, or a spherical or toroidal face.
pub of: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction point where a line meets a plane.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PointEdgePlaneParams {
/// The line: a straight edge, a sketch line, X, Y, Z or a construction axis (a circular edge
/// or a cylinder, cone or torus face gives its axis), not parallel to the plane.
pub edge: GeoRefParam,
/// The plane or planar face.
pub plane: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction point part way along a path.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PointAlongPathParams {
/// The path: an edge or a sketch curve, with some length.
pub path: GeoRefParam,
/// How far along the path, as a fraction of its length: 0 at its start, 1 at its end (a
/// value outside is taken as the nearer end). Default: 0.5.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "NumberExpr")]
pub t: Option<NumberExpr>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: ConstructionCommon,
}
/// Add a construction plane halfway between two planes: midway between parallel ones, else
/// through the line where they meet, halving the angle between them.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MidplaneParams {
/// The first plane or planar face; between parallel planes, the new plane faces as this one.
pub a: GeoRefParam,
/// The second plane or planar face.
pub b: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// Add a construction plane through two lines in one plane.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PlaneTwoEdgesParams {
/// The first line: a straight edge, a sketch line, X, Y, Z or a construction axis (a circular
/// edge or a cylinder, cone or torus face gives its axis).
pub a: GeoRefParam,
/// The second line, in one plane with the first but not on the same line.
pub b: 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)]
pub struct PlaneThreePointsParams {
/// The first point (the plane's origin): a vertex, a construction point, a sketch point, a
/// fixed point, or the centre of a circular edge, a sketch circle or arc, or a spherical or
/// toroidal face.
pub a: GeoRefParam,
/// The second point.
pub b: GeoRefParam,
/// The third point, not on the line through the first two.
pub c: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// Add a construction plane that touches a face at a point.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PlaneTangentParams {
/// The face: a plane, or a planar, cylindrical, conical, spherical or toroidal face.
pub face: GeoRefParam,
/// Where the plane touches the face, in mm: the point of the face nearest it. Not on the
/// axis of a round face. Default, for a face picked by a point: that point; required
/// otherwise.
#[serde(default)]
pub at: Option<Point3>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// Add a construction plane square to a path, part way along it.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PlaneAlongPathParams {
/// The path: an edge or a sketch curve, with some length.
pub path: GeoRefParam,
/// How far along the path, as a fraction of its length: 0 at its start, 1 at its end (a
/// value outside is taken as the nearer end). Default: 0.5.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "NumberExpr")]
pub t: Option<NumberExpr>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// Add a construction plane that holds a line and is square to a plane.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PlanePerpendicularParams {
/// The plane or planar face the new plane is square to.
pub plane: GeoRefParam,
/// The line it holds: a straight edge, a sketch line, X, Y, Z or a construction axis (a
/// circular edge or a cylinder, cone or torus face gives its axis), not square to `plane`.
pub line: GeoRefParam,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
#[cfg(test)]
#[path = "construct_tests.rs"]
mod tests;
+133
View File
@@ -0,0 +1,133 @@
use serde_json::{Value, json};
use super::{
AxisEdgeParams, AxisNormalToFaceParams, AxisThroughCylinderParams, AxisTwoPlanesParams, AxisTwoPointsParams, MidplaneParams,
PlaneAlongPathParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams, PointAlongPathParams,
PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams,
};
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<AxisThroughCylinderParams>(), &["face", "name", "participants"]);
assert_keys(input_schema_for::<AxisNormalToFaceParams>(), &["face", "name", "participants", "point"]);
assert_keys(input_schema_for::<AxisTwoPlanesParams>(), &["a", "b", "name", "participants"]);
assert_keys(input_schema_for::<AxisTwoPointsParams>(), &["a", "b", "name", "participants"]);
assert_keys(input_schema_for::<AxisEdgeParams>(), &["edge", "name", "participants"]);
assert_keys(input_schema_for::<PointVertexParams>(), &["name", "participants", "point"]);
assert_keys(input_schema_for::<PointTwoEdgesParams>(), &["a", "b", "name", "participants"]);
assert_keys(input_schema_for::<PointThreePlanesParams>(), &["a", "b", "c", "name", "participants"]);
assert_keys(input_schema_for::<PointCenterParams>(), &["name", "of", "participants"]);
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::<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"]);
assert_keys(input_schema_for::<PlaneAlongPathParams>(), &["name", "participants", "path", "t"]);
assert_keys(input_schema_for::<PlanePerpendicularParams>(), &["line", "name", "participants", "plane"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("construct.axis.edge", json!({"edge": 5})),
("construct.axis.edge", json!({"edge": {"edge": "x"}})),
("construct.axis.edge", json!({"edge": null})),
("construct.axis.through_cylinder", json!({"face": {"type": "bogus"}})),
("construct.axis.through_cylinder", json!({"face": {"type": "face", "at": [1, 2]}})),
("construct.axis.two_points", json!({"a": {"sketch": true, "point": "p1"}, "b": {"point": [0, 0, 0]}})),
("construct.axis.two_points", json!({"a": {"sketch": 1, "point": [1, 2, 3]}, "b": {"point": [0, 0, 0]}})),
("construct.point.vertex", json!({"point": {"sketch": 1}})),
("construct.point.vertex", json!({"point": {"type": "point", "p": false}})),
("construct.point.vertex", json!({"point": {"type": "point"}})),
("construct.point.vertex", json!({"point": {"construct_point": 5}})),
("construct.plane.midplane", json!({"a": {"plane": 2.5}, "b": "XY"})),
("construct.plane.midplane", json!({"a": {"plane": -1}, "b": "XY"})),
("construct.plane.midplane", json!({"a": {"plane": null}, "b": "XY"})),
("construct.plane.midplane", json!({"a": {"plane": {"normal": [0, 0, 0]}}, "b": "XY"})),
("construct.plane.midplane", json!({"a": {"plane": {"x_dir": [1, 0, 0], "y_dir": [2, 0, 0], "normal": [0, 0, 1]}}, "b": "XY"})),
("construct.plane.midplane", json!({"a": {"plane": {"origin": [1, 2], "normal": [0, 0, 1]}}, "b": "XY"})),
("construct.plane.midplane", json!({"a": {"plane": {"origin": null, "normal": [0, 0, 1]}}, "b": "XY"})),
]);
}
/// Every construction command answers with `FeatureResult`, and its contract takes every form
/// these calls use (in Warn, a contract that refused one would still run the command, so the
/// replies are checked for warnings too).
#[test]
fn construction_features_reply_with_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
// A 10 mm cube at the origin, a cylinder beside it and a sketch with a line.
run("solid.box", json!({"length": 10, "width": 10, "height": 10}));
run("solid.cylinder", json!({"radius": 5, "height": 10, "base": [30, 0, 0]}));
let sketch = run("sketch.create", json!({"plane": "XY", "name": "Guide"}));
let line = run("sketch.line", json!({"points": [[0, -20], [20, -20]]}));
run("sketch.finish", json!({}));
let (sketch, curve, point) = (sketch["sketch"].clone(), line["curves"][0].clone(), line["created"]["points"][0].clone());
assert!(sketch.is_u64() && curve.is_string() && point.is_string(), "{sketch} {line}");
let replies = vec![
run("construct.point.vertex", json!({"point": {"vertex": [10, 10, 10]}, "name": "Corner"})),
run("construct.point.vertex", json!({"point": {"sketch": "Guide", "point": point}})),
run("construct.point.vertex", json!({"point": {"edge": [35, 0, 10]}})),
run("construct.point.two_edges", json!({"a": {"axis": "X"}, "b": {"axis": "y"}})),
run("construct.point.three_planes", json!({"a": "XY", "b": {"plane": "XZ"}, "c": {"face": [10, 5, 5]}})),
run("construct.point.center", json!({"of": {"edge": [35, 0, 10]}})),
run("construct.point.edge_plane", json!({"edge": {"axis": "Z"}, "plane": {"plane": {"origin": [0, 0, "3 mm"], "normal": [0, 0, 1]}}})),
run("construct.point.along_path", json!({"path": {"sketch": sketch, "curve": curve}, "t": "1 / 4"})),
run("construct.point.along_path", json!({"path": {"edge": [5, 0, 0]}})),
run("construct.axis.through_cylinder", json!({"face": {"face": [35, 0, 5]}, "name": "Bore"})),
run("construct.axis.normal_to_face", json!({"face": {"face": [5, 5, 10]}, "point": {"construct_point": "Corner"}})),
run("construct.axis.two_planes", json!({"a": "XY", "b": {"plane": {"x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}})),
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.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]}})),
run("construct.plane.tangent", json!({"face": {"face": [30, 5, 5]}, "at": [30, 6, 5], "participants": ["Body1"]})),
run("construct.plane.along_path", json!({"path": {"edge": [5, 0, 0]}})),
run("construct.plane.perpendicular", json!({"plane": "XY", "line": {"edge": [5, 0, 0]}})),
// The offset and at-angle planes' contracts are in schema/features.rs; they run here
// because they use this design's `Top`, its faces and the `Bore` axis.
run("construct.plane.offset", json!({"base": "Top", "offset": 5, "name": "Shelf"})),
run("construct.plane.offset", json!({"base": "Shelf", "offset": "-2 mm"})),
run("construct.plane.offset", json!({"base": {"face": [5, 5, 10]}, "offset": 2})),
run("construct.plane.offset", json!({"base": "XZ", "through": {"vertex": [10, 10, 10]}, "offset": null})),
run("construct.plane.offset", json!({"base": {"origin": ["1 mm", 0, 0], "normal": [1, 0, 0]}, "offset": 1, "through": null})),
run("construct.plane.at_angle", json!({"base": "XY", "axis": "x", "angle": 30})),
run("construct.plane.at_angle", json!({"base": "XZ", "axis": {"edge": [5, 0, 0]}, "angle": "45 deg"})),
run("construct.plane.at_angle", json!({"base": {"face": [5, 5, 10]}, "axis": {"dir": [0, 1, 0]}, "angle": 10})),
run("construct.plane.at_angle", json!({"base": "XY", "axis": "Bore", "angle": 90})),
];
// Warn reports what the contract refuses and runs the command anyway: a construction feature
// makes no body, and a construction plane may not take an origin plane's name.
let no_body = run("construct.axis.edge", json!({"edge": {"axis": "X"}, "body_name": "Rod"}));
let warning = no_body["param_warnings"][0].as_str().unwrap_or_default();
assert!(warning.starts_with("unknown key `body_name`"), "{no_body}");
for r in replies.iter().chain([&no_body]) {
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
for r in &replies {
assert!(r.get("param_warnings").is_none(), "{r}");
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!([]), &json!([])), "{r}");
}
// A construction plane's name that an origin plane has is refused with the same message,
// but strict names the command where the handler (`add_feature`, which doesn't know it) says
// `construct.plane`.
let refused = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("construct.plane.midplane", &json!({"a": "XY", "b": {"plane": {"origin": [0, 0, 10], "normal": [0, 0, 1]}}, "name": " front "}))
.err()
.map(|e| e.to_string())
};
let message = "`front` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name";
assert_eq!(refused(ParamCheck::Off), Some(crate::params::bad("construct.plane", message).to_string()));
assert_eq!(refused(ParamCheck::Strict), Some(crate::params::bad("construct.plane.midplane", message).to_string()));
}
+38 -3
View File
@@ -9,9 +9,9 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::types::{
AngleExpr, AxisDir, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon, FromPoint, IdOrName, LengthExpr,
NumberExpr, OperationParam, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint, feature_common,
present,
AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon, FromPoint,
GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors,
SketchRef, StringList, ThinSideParam, ToPoint, feature_common, present,
};
/// Add a box primitive.
@@ -367,6 +367,41 @@ pub struct ScaleParams {
pub participants: Option<StringList>,
}
/// Add a construction plane parallel to `base`: `offset` away along its normal, or through a
/// point.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct OffsetPlaneParams {
/// The plane it is parallel to.
pub base: PlaneOrFace,
/// How far from `base`, along its normal (negative: the other way). Required unless
/// `through` is given; not with `through` (`null` counts as not given).
#[serde(default)]
pub offset: Option<LengthExpr>,
/// A point the plane goes through instead: a vertex, a construction point, a sketch point, a
/// fixed point, or the centre of a circular edge, a sketch circle or arc, or a spherical or
/// toroidal face. `null` counts as not given.
#[serde(default)]
pub through: Option<GeoRefParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// Add a construction plane: `base` turned by `angle` about an axis.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PlaneAtAngleParams {
/// The plane to turn.
pub base: PlaneOrFace,
/// The axis it turns about. A picked edge's or face's axis points the way its largest
/// component is positive.
pub axis: AxisLine,
/// How far to turn, counterclockwise looking back along `axis`'s direction.
pub angle: AngleExpr,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: PlaneCommon,
}
/// What a feature command returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureResult {
+22 -1
View File
@@ -1,7 +1,8 @@
use serde_json::{Value, json};
use super::{
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, PipeParams, ScaleParams, SphereParams, TorusParams,
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams,
ScaleParams, SphereParams, TorusParams,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
@@ -83,6 +84,8 @@ fn schemas_and_the_key_check_agree() {
);
assert_keys(input_schema_for::<BoundingSolidParams>(), &["bodies", "body_name", "body_names", "margin", "name", "participants"]);
assert_keys(input_schema_for::<ScaleParams>(), &["bodies", "factor", "factors", "name", "origin", "participants"]);
assert_keys(input_schema_for::<OffsetPlaneParams>(), &["base", "name", "offset", "participants", "through"]);
assert_keys(input_schema_for::<PlaneAtAngleParams>(), &["angle", "axis", "base", "name", "participants"]);
}
#[test]
@@ -135,6 +138,24 @@ fn strict_keeps_todays_errors() {
("solid.scale", json!({"bodies": ["Body1"], "factors": [1, 2]})),
("solid.scale", json!({"bodies": ["Body1"], "factors": [1, 2, 3, 4]})),
("solid.scale", json!({"bodies": ["Body1"]})),
("construct.plane.offset", json!({"base": true, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [1, 2]}, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "fixed": "yes"}, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "body": ""}, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "body": "Body1"}, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "index": -1}, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "path": [0]}, "offset": 5})),
("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "path": vec![1; 65]}, "offset": 5})),
("construct.plane.offset", json!({"base": {"normal": [0, 0, 1], "origin": "here"}, "offset": 5})),
("construct.plane.offset", json!({"base": "XY", "through": {"edge": "x"}})),
("construct.plane.offset", json!({"base": "XY", "offset": true})),
("construct.plane.at_angle", json!({"base": "XY", "axis": {"dir": [0, 0, 0]}, "angle": 30})),
("construct.plane.at_angle", json!({"base": "XY", "axis": {"origin": "o", "dir": [0, 0, 1]}, "angle": 30})),
("construct.plane.at_angle", json!({"base": "XY", "axis": 5, "angle": 30})),
("construct.plane.at_angle", json!({"base": "XY", "axis": null, "angle": 30})),
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x", "point": [0, 0, 0]}, "angle": 30})),
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x"}, "angle": 30})),
("construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": true})),
]);
}
+1
View File
@@ -55,6 +55,7 @@ mod tests;
// The contracts, by command file.
pub mod component;
pub mod construct;
pub mod edit;
pub mod features;
pub mod inspect;
+174
View File
@@ -852,3 +852,177 @@ fn picks_are_still_looked_up_one_at_a_time() {
let r = s.execute("solid.fillet", &fillet(json!([{"body": "Body1", "index": 0}]))).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}");
}
/// A reference is accepted as `construct::geo` reads it before it looks anything up, and its
/// schema says the same, the first form that fits deciding, apart from what a schema can't say
/// (below).
#[test]
fn references_match_the_reference_reader() {
let schema = input_schema_for::<GeoRefParam>();
let schema = schema.as_value();
for (v, accepted) in [
(json!({"face": [1, 2, 3]}), true),
(json!({"edge": {"x": 1, "y": 2, "z": 3}}), true),
(json!({"vertex": [1, 2, 3], "body": "Body1"}), true),
(json!({"face": "F1", "edge": [1, 2, 3]}), true),
(json!({"face": [1e9, 0, 0]}), false),
(json!({"type": "point", "p": [1, 2, 3]}), true),
(json!({"type": "origin"}), true),
(json!({"type": "bogus"}), false),
(json!({"type": null, "face": [1, 2, 3]}), false),
(json!({"sketch": 3, "point": "p1"}), true),
(json!({"sketch": "Guide", "curve": "l1"}), true),
(json!({"sketch": 2.5, "point": [1, 2], "curve": "l1"}), true),
(json!({"sketch": true, "point": "p1"}), false),
(json!({"sketch": 3}), false),
(json!({"sketch": null, "point": [1, 2, 3]}), false),
(json!({"point": [1, 2, 3]}), true),
(json!({"point": "p1"}), false),
(json!({"plane": "XY"}), true),
(json!({"plane": 4}), true),
(json!({"plane": -4}), false),
(json!({"plane": {"normal": [0, 0, 1]}}), true),
(json!({"plane": {"origin": ["w", 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}), true),
(json!({"plane": {"x_dir": [1, 0, 0]}}), false),
(json!({"plane": {"origin": [0, 0], "normal": [0, 0, 1]}}), false),
(json!({"plane": 2.5, "axis": "X"}), false),
(json!({"point": [1, 2, 3], "plane": 2.5}), true),
(json!({"axis": "X"}), true),
(json!({"axis": 1, "construct_point": "P"}), true),
(json!({"construct_point": "P"}), true),
(json!({"construct_point": 1}), false),
(json!("Shelf"), true),
(json!(""), true),
(json!(5), false),
(json!(null), false),
(json!([1, 2, 3]), false),
(json!({}), false),
] {
let form = match crate::cmd::geo_form(&v) {
Ok(crate::cmd::GeoForm::Sketch { sketch, part }) => crate::cmd::sketch_key(Some(sketch)).is_some() && part.is_some(),
Ok(crate::cmd::GeoForm::Plane(plane)) => parse::<PlaneParam>(plane.clone()).is_some(),
form => form.is_ok(),
};
assert_eq!(form, accepted, "reader {v}");
assert_eq!(parse::<GeoRefParam>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}");
}
// Where the schema can't follow the reader: a saved reference is read whole by serde, and an
// explicit plane's directions must make a plane.
for v in [json!({"type": "face", "at": "x"}), json!({"plane": {"normal": [0, 0, 0]}})] {
assert!(parse::<GeoRefParam>(v.clone()).is_none() && conforms(schema, schema, &v), "{v}");
}
}
/// A plane is accepted as `features::plane_param` reads it before it looks anything up, and
/// so is a plane or face `base` (a face as `sketch::face::capture` reads it); their schemas say
/// the same, apart from directions that don't make a plane.
#[test]
fn planes_match_the_plane_readers() {
let plane = input_schema_for::<PlaneParam>();
let plane = plane.as_value();
let base = input_schema_for::<PlaneOrFace>();
let base = base.as_value();
let face = |extra: Value| {
let mut v = json!({"face": [1, 2, 3]});
if let (Some(v), Some(extra)) = (v.as_object_mut(), extra.as_object()) {
v.extend(extra.clone());
}
v
};
for (v, as_plane, as_base) in [
(json!("XY"), true, true),
(json!("anything"), true, true),
(json!(7), true, true),
(json!(u64::MAX), true, true),
(json!(-7), false, false),
(json!(7.5), false, false),
(json!(true), false, false),
(json!(null), false, false),
(json!([0, 0, 1]), false, false),
(json!({}), false, false),
(json!({"origin": [0, 0, "h"], "normal": [0, 0, 1]}), true, true),
(json!({"origin": {"x": 0, "y": 0, "z": 1}, "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), true, true),
(json!({"x_dir": [1, 0, 0], "y_dir": "y", "normal": [0, 0, 1]}), true, true),
(json!({"x_dir": [1, 0, 0]}), false, false),
(json!({"origin": {"x": 0, "y": 0}, "normal": [0, 0, 1]}), false, false),
(json!({"origin": null, "normal": [0, 0, 1]}), false, false),
(face(json!({})), false, true),
(face(json!({"normal": [0, 0, 1]})), true, true),
(face(json!({"body": "Body1", "index": 0, "path": [1, 2], "fixed": true})), false, true),
(face(json!({"body": "Body1"})), false, false),
(face(json!({"index": 0})), false, false),
(face(json!({"body": "", "index": 0})), false, false),
(face(json!({"body": "Body1", "index": -1})), false, false),
(face(json!({"body": "Body1", "index": 1.0})), false, false),
(face(json!({"path": [0]})), false, false),
(face(json!({"path": vec![1; 64]})), false, true),
(face(json!({"path": vec![1; 65]})), false, false),
(face(json!({"fixed": "yes"})), false, false),
(json!({"face": "x", "normal": [0, 0, 1]}), true, false),
(json!({"face": null}), false, false),
] {
assert_eq!(parse::<PlaneParam>(v.clone()).is_some(), as_plane, "{v}");
assert_eq!(conforms(plane, plane, &v), as_plane, "plane schema {v}");
let reader = match v.get("face") {
Some(_) => crate::cmd::face_pick_form(&v).is_ok(),
None => as_plane,
};
assert_eq!(reader, as_base, "reader {v}");
assert_eq!(parse::<PlaneOrFace>(v.clone()).is_some(), as_base, "{v}");
assert_eq!(conforms(base, base, &v), as_base, "base schema {v}");
}
for v in [json!({"normal": [0, 0, 0]}), json!({"x_dir": [1, 0, 0], "y_dir": [2, 0, 0], "normal": [0, 0, 1]})] {
assert!(parse::<PlaneParam>(v.clone()).is_none() && conforms(plane, plane, &v), "{v}");
}
// An id, an index or a path id written with a fraction (`7.0`) is refused by the readers but
// is an integer to JSON Schema (`conforms` reads integers as serde does, so it agrees with
// the readers above; a standard validator would not).
assert!(parse::<PlaneParam>(json!(7.0)).is_none() && parse::<PlaneOrFace>(face(json!({"path": [1.0]}))).is_none());
}
/// A plane at an angle's `axis` is accepted as `features::axis_line` reads it before it looks
/// anything up, and its schema says the same, apart from what a schema can't say (below).
#[test]
fn axis_lines_match_the_axis_reader() {
let schema = input_schema_for::<AxisLine>();
let schema = schema.as_value();
for (v, accepted) in [
(json!("X"), true),
(json!("Bore"), true),
(json!({"dir": [0, 0, 1]}), true),
(json!({"origin": [1, 2, 3], "dir": {"x": 0, "y": 1, "z": 0}}), true),
(json!({"origin": ["1 mm", 0, 0], "dir": [0, 0, 1]}), false),
(json!({"dir": [0, 0, 1], "type": "world", "axis": "X"}), true),
(json!({"edge": [1, 2, 3]}), true),
(json!({"face": [1, 2, 3], "dir": [0, 0, 1]}), true),
(json!({"face": "x", "dir": [0, 0, 1]}), false),
(json!({"type": "edge", "at": [1, 2, 3]}), true),
(json!({"type": "vertex", "at": [1, 2, 3]}), false),
(json!({"face": "x", "vertex": [1, 2, 3]}), false),
(json!({"edge": "x", "sketch": 1, "curve": "l1"}), false),
(json!({}), false),
(json!(5), false),
(json!(null), false),
(json!([0, 0, 1]), false),
] {
assert_eq!(parse::<AxisLine>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}");
}
// Where the schema can't follow the reader: a saved edge is read whole by serde, and `dir`
// must not be zero.
for v in [json!({"type": "edge", "at": "x"}), json!({"dir": [0, 0, 0]})] {
assert!(parse::<AxisLine>(v.clone()).is_none() && conforms(schema, schema, &v), "{v}");
}
}
/// A construction plane's name is refused when `features::check_plane_name` refuses it. (The
/// schema can't say which names: see `PlaneName`.)
#[test]
fn plane_names_match_check_plane_name() {
for name in ["Shelf", "XY", " top ", "Front\u{a0}", "RIGHT", "XYZ", "", "前"] {
let refused = crate::cmd::plane_name_problem(name);
assert_eq!(parse::<PlaneName>(json!(name)).is_none(), refused.is_some(), "{name:?}");
}
assert!(parse::<PlaneName>(json!(5)).is_none());
}
+312 -6
View File
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{Expr, Operation, ThinSide};
use solidcraft_geom::{Vec2, Vec3};
use super::check::whole;
use super::check::{keyed, whole};
use crate::params;
/// For optional fields whose key may be absent but not `null`: absent is `None` (with
@@ -818,6 +818,311 @@ impl JsonSchema for SketchRef {
}
}
/// What `features::plane_param` says about `v` as a plane before it looks anything up, as a
/// complete message, or `None`. Names and ids are the handler's to look up.
fn plane_problem(v: &Value) -> Option<String> {
let forms = || whole(format!("`plane` must be {}", crate::cmd::PLANE_FORMS));
match v {
Value::String(_) => None,
Value::Number(n) => n.as_u64().map_or_else(|| Some(forms()), |_| None),
Value::Object(o) => {
// The handler evaluates each coordinate after reading it; an expression that doesn't
// evaluate is its to report.
if o.get("origin").is_some_and(|origin| PointExpr3::deserialize(origin).is_err()) {
return Some(whole("`origin` must be a point [x, y, z] of numbers or length expressions"));
}
crate::cmd::explicit_plane(v, Vec3::ZERO).map_or_else(|| Some(whole("plane needs x_dir and y_dir, or normal")), |_| None)
}
_ => Some(forms()),
}
}
/// A plane, as `features::plane_param` reads it: an origin plane's name or alias, a construction
/// plane's name or feature id, or an explicit plane object. Kept as given, for the handler.
/// Whether a name or id names a plane is checked by the handler, which has the design.
#[derive(Clone, Debug, PartialEq)]
pub struct PlaneParam(pub Value);
impl<'de> Deserialize<'de> for PlaneParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match plane_problem(&v) {
Some(why) => Err(D::Error::custom(why)),
None => Ok(PlaneParam(v)),
}
}
}
impl JsonSchema for PlaneParam {
fn schema_name() -> Cow<'static, str> {
"Plane".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let origin = generator.subschema_for::<PointExpr3>();
json_schema!({
"description": "A plane: XY, XZ or YZ (or Top, Front or Right; any case), a construction plane's name or feature id, or an explicit plane {\"origin\"?, \"x_dir\", \"y_dir\"} or {\"origin\"?, \"normal\"}: directions [x, y, z] (two that aren't parallel, or a normal that isn't zero; the two directions win over normal when both are points) and an origin in mm, each coordinate a number or a length expression (default: the origin).",
"anyOf": [
{"type": "string"},
{"type": "integer", "minimum": 0, "maximum": u64::MAX},
{
"type": "object",
"properties": {"origin": origin},
"anyOf": [
{"properties": {"x_dir": point, "y_dir": point}, "required": ["x_dir", "y_dir"]},
{"properties": {"normal": point}, "required": ["normal"]}
]
}
]
})
}
}
/// What `construct::geo` says about `v` as a reference before it looks anything up, in the
/// contract's style (the key goes in front), or `None`. Sketches, axes and construction points
/// are the handler's to look up.
fn geo_problem(v: &Value) -> Option<String> {
use crate::cmd::{GeoForm, GeoFormError};
match crate::cmd::geo_form(v) {
Ok(GeoForm::Sketch { sketch, part }) => {
if crate::cmd::sketch_key(Some(sketch)).is_none() {
Some("must be a sketch id or name".into())
} else {
part.is_none().then(|| crate::cmd::SKETCH_PART.into())
}
}
Ok(GeoForm::Plane(plane)) => plane_problem(plane),
Ok(_) => None,
Err(GeoFormError::Saved(why)) => Some(keyed(why)),
Err(GeoFormError::Unknown) => Some(format!("must be {}", crate::cmd::GEO_REF_FORMS)),
}
}
/// The reference types a saved reference's `type` names (`solidcraft_doc::construct::GeoRef`).
const SAVED_REFS: [&str; 11] =
["point", "origin", "vertex", "edge", "face", "plane", "world", "axis", "construct_point", "sketch_point", "sketch_curve"];
/// `{key: point}`, unless it has `type` (then it is a saved reference).
fn picked_at(key: &str, point: &Schema) -> Value {
json!({"type": "object", "properties": {key: point}, "required": [key], "not": {"required": ["type"]}})
}
/// A reference to geometry for a construction axis, point or plane, as `construct::geo` reads
/// it. Kept as given, for the handler. What the reference must be (a plane, a line…) depends on
/// the key and is checked when the feature is evaluated; whether a sketch, axis or construction
/// point it names exists is checked by the handler.
#[derive(Clone, Debug, PartialEq)]
pub struct GeoRefParam(pub Value);
impl<'de> Deserialize<'de> for GeoRefParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match geo_problem(&v) {
Some(why) => Err(D::Error::custom(why)),
None => Ok(GeoRefParam(v)),
}
}
}
impl JsonSchema for GeoRefParam {
fn schema_name() -> Cow<'static, str> {
"GeoRef".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let plane = generator.subschema_for::<PlaneParam>();
let sketch = generator.subschema_for::<SketchRef>();
let text = json!({"type": "string"});
// The reader takes the first form that fits, in the order below; the `not`s keep a later
// form from taking an object an earlier one refuses.
let earlier = |keys: &[&str]| json!({"anyOf": keys.iter().map(|k| json!({"required": [k]})).collect::<Vec<_>>()});
json_schema!({
"description": "Geometry to build from, the first of these forms that fits: a reference as saved ({\"type\": …}, as timeline.inspect gives one back); a point in mm on a face, edge or vertex of a body ({\"face\": [x, y, z]}, {\"edge\": …} or {\"vertex\": …}), kept by its persistent name; a sketch's point or curve ({\"sketch\": id or name, \"point\" or \"curve\": its id}); a fixed point {\"point\": [x, y, z]}; a plane {\"plane\": …}; an axis {\"axis\": X, Y, Z or a construction axis's name}; a construction point {\"construct_point\": name}; or a plane's name.",
"anyOf": [
{"type": "object", "properties": {"type": {"enum": SAVED_REFS}}, "required": ["type"]},
picked_at("face", &point),
picked_at("edge", &point),
picked_at("vertex", &point),
{
"type": "object",
"properties": {"sketch": sketch},
"required": ["sketch"],
"anyOf": [{"properties": {"point": text}, "required": ["point"]}, {"properties": {"curve": text}, "required": ["curve"]}],
"not": earlier(&["type"])
},
{"type": "object", "properties": {"point": point}, "required": ["point"], "not": earlier(&["type", "sketch"])},
{"type": "object", "properties": {"plane": plane}, "required": ["plane"], "not": earlier(&["type", "sketch"])},
{"type": "object", "properties": {"axis": text}, "required": ["axis"], "not": earlier(&["type", "sketch", "plane"])},
{"type": "object", "properties": {"construct_point": text}, "required": ["construct_point"], "not": earlier(&["type", "sketch", "plane"])},
text
]
})
}
}
/// A construction plane's `base`, as `construct.plane.offset` and `construct.plane.at_angle`
/// read it: a planar face of a body when it has `face` (as `sketch::face::capture` reads it),
/// otherwise a [`PlaneParam`]. Kept as given, for the handler.
#[derive(Clone, Debug, PartialEq)]
pub struct PlaneOrFace(pub Value);
impl<'de> Deserialize<'de> for PlaneOrFace {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let problem = match v.get("face") {
Some(_) => crate::cmd::face_pick_form(&v).err().map(whole),
None => plane_problem(&v),
};
match problem {
Some(why) => Err(D::Error::custom(why)),
None => Ok(PlaneOrFace(v)),
}
}
}
impl JsonSchema for PlaneOrFace {
fn schema_name() -> Cow<'static, str> {
"PlaneOrFace".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let plane = generator.subschema_for::<PlaneParam>();
let index_max = u64::try_from(usize::MAX).unwrap_or(u64::MAX);
json_schema!({
"description": "A plane, or a planar face of a body {\"face\": [x, y, z] in world coordinates, \"body\"?, \"index\"?, \"path\"?, \"fixed\"?}, which the plane follows as the model changes (\"fixed\": true keeps it where the face is now). \"body\" (the picked body's world name) and \"index\" (the face's index in it) pick the face exactly and go together; \"path\" is the occurrence path of the body's placement, at most 64 ids.",
"anyOf": [
{
"type": "object",
"properties": {
"face": point,
"fixed": {"type": "boolean"},
"body": {"type": "string", "minLength": 1},
"index": {"type": "integer", "minimum": 0, "maximum": index_max},
"path": {"type": "array", "maxItems": 64, "items": {"type": "integer", "minimum": 1, "maximum": u64::MAX}}
},
"required": ["face"],
"anyOf": [{"required": ["body", "index"]}, {"not": {"anyOf": [{"required": ["body"]}, {"required": ["index"]}]}}]
},
{"anyOf": [plane], "not": {"type": "object", "required": ["face"]}}
]
})
}
}
/// The line `construct.plane.at_angle` turns about, as `features::axis_line` reads it: an edge or
/// face of the model (as `construct::model_axis_param` reads it), else X, Y, Z, a construction
/// axis's name or `{origin?, dir}`. Kept as given, for the handler. Whether a picked edge or face
/// gives a line, or a name names an axis, is checked by the handler, which has the design.
#[derive(Clone, Debug, PartialEq)]
pub struct AxisLine(pub Value);
impl<'de> Deserialize<'de> for AxisLine {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
geo_problem(&v).or_else(|| match crate::cmd::geo_form(&v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
} else {
match &v {
Value::String(_) => None,
Value::Object(_) => crate::cmd::axis_line_object(&v).err().map(whole),
_ => Some(whole("`axis` is required")),
}
};
match problem {
Some(why) => Err(D::Error::custom(why)),
None => Ok(AxisLine(v)),
}
}
}
impl JsonSchema for AxisLine {
fn schema_name() -> Cow<'static, str> {
"AxisLine".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let saved = json!({"required": ["type"], "properties": {"type": {"enum": ["edge", "face"]}}});
json_schema!({
"description": "An axis: X, Y or Z (any case), a construction axis's name (where it is now), {\"origin\"?: [x, y, z] in mm (default: the origin), \"dir\": [x, y, z] that isn't zero}, or a straight or circular edge, or a cylinder, cone or torus face, of a body ({\"edge\": [x, y, z]} or {\"face\": [x, y, z]}, a point on it, or as saved: {\"type\": \"edge\" or \"face\", \"at\", \"name\"?}), whose axis the plane follows as the model changes.",
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {"origin": point, "dir": point},
"required": ["dir"],
"not": {"anyOf": [{"required": ["edge"]}, {"required": ["face"]}, saved]}
},
picked_at("face", &point),
picked_at("edge", &point),
{"type": "object", "properties": {"type": {"enum": ["edge", "face"]}}, "required": ["type"]}
]
})
}
}
/// A construction plane's name, which `features::check_plane_name` refuses when it names an
/// origin plane. Strict gives the same message but names the command that was called, where the
/// handler (`add_feature`, which isn't given it) says `construct.plane`.
#[derive(Clone, Debug, PartialEq)]
pub struct PlaneName(pub String);
impl<'de> Deserialize<'de> for PlaneName {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(name) => match crate::cmd::plane_name_problem(&name) {
Some(why) => Err(D::Error::custom(whole(why))),
None => Ok(PlaneName(name)),
},
_ => Err(D::Error::custom("must be text")),
}
}
}
impl JsonSchema for PlaneName {
fn schema_name() -> Cow<'static, str> {
"PlaneName".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
// The schema can't say which names are refused: a pattern would need a lookahead, which
// not every validator has.
json_schema!({
"description": "The plane's name in the timeline: not XY, XZ, YZ, Top, Front or Right (any case, with or without spaces around it), which name the origin planes.",
"type": "string"
})
}
}
/// The keys `features::add_feature` reads that a construction axis or point takes besides its
/// references, flattened into its parameters with [`feature_common`]. Not [`FeatureCommon`]: it
/// makes no body, so `body_name` and `body_names` would do nothing. `participants` stays because
/// `timeline.redefine` passes a feature's own back.
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
pub struct ConstructionCommon {
/// The feature's name in the timeline.
#[serde(default)]
pub name: Option<String>,
/// The bodies taking part, which every feature records; each an existing body or `*`. A
/// construction feature changes no body, whatever this lists.
#[serde(default)]
pub participants: Option<StringList>,
}
/// [`ConstructionCommon`] for a construction plane, whose name may not name an origin plane.
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
pub struct PlaneCommon {
#[serde(default)]
pub name: Option<PlaneName>,
/// The bodies taking part, which every feature records; each an existing body or `*`. A
/// construction feature changes no body, whatever this lists.
#[serde(default)]
pub participants: Option<StringList>,
}
/// The keys `features::add_feature` reads for every feature command, whatever else it takes:
/// the feature's name, the new bodies' names and the bodies taking part. A feature command's
/// parameters include it with
@@ -849,11 +1154,12 @@ pub struct FeatureCommon {
pub participants: Option<StringList>,
}
/// Deserializes a flattened [`FeatureCommon`] from the keys the parent doesn't take, and reports
/// a problem as if its fields were the parent's own, with the key in the message and in the
/// issue's path (serde's flatten otherwise loses the key). serde reads a flattened field after
/// all the parent's own, so its problems come after theirs, including a missing key.
pub fn feature_common<'de, D: Deserializer<'de>>(d: D) -> Result<FeatureCommon, D::Error> {
/// Deserializes a flattened [`FeatureCommon`] (or [`ConstructionCommon`], [`PlaneCommon`]) from
/// the keys the parent doesn't take, and reports a problem as if its fields were the parent's
/// own, with the key in the message and in the issue's path (serde's flatten otherwise loses the
/// key). serde reads a flattened field after all the parent's own, so its problems come after
/// theirs, including a missing key.
pub fn feature_common<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(d: D) -> Result<T, D::Error> {
let v = Value::deserialize(d)?;
serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e)))
}
-19
View File
@@ -51,25 +51,6 @@ config.row
config.rule
config.rules
config.table
construct.axis.edge
construct.axis.normal_to_face
construct.axis.through_cylinder
construct.axis.two_planes
construct.axis.two_points
construct.plane.along_path
construct.plane.at_angle
construct.plane.midplane
construct.plane.offset
construct.plane.perpendicular
construct.plane.tangent
construct.plane.three_points
construct.plane.two_edges
construct.point.along_path
construct.point.center
construct.point.edge_plane
construct.point.three_planes
construct.point.two_edges
construct.point.vertex
contact.check
contact.create
contact.disable_all
+17 -2
View File
@@ -50,7 +50,8 @@ handler and everything it passes `p` to:
`targets` overrides it), in that key's description; where it has no effect, in the summary or
in the description of the key it might seem to change (bounding solid's `bodies`). A feature
that makes no body (scale) declares `name` and `participants` itself instead, as `body_name`
and `body_names` would do nothing. It keeps `participants` because `timeline.redefine` passes
and `body_names` would do nothing; a construction axis, point or plane flattens
`ConstructionCommon` or `PlaneCommon`, which hold just those two. It keeps `participants` because `timeline.redefine` passes
a feature's own back, though for a scale it changes nothing either; its own description says
so. For the primitives (box, cylinder, sphere, torus, cone) `add_feature` also reads `targets`;
a new primitive must join the `primitive` match in `add_feature_in`, and its contract declares
@@ -259,6 +260,12 @@ calling that reader.
| `ScaleFactors` | scale's `factors`: three `NumberExpr`s (strict words a bad item differently; see its doc) | `features::scale` |
| `AxisDir` | X, Y or Z, or a non-zero `[x, y, z]` / `{x, y, z}` (cylinder's, cone's and coil's `axis`) | `features::axis_dir_form` |
| `FeatureCommon` | `name`, `body_name`, `body_names`, `participants`, flattened | `features::add_feature` |
| `ConstructionCommon`, `PlaneCommon` | `name` and `participants` of a construction axis or point, or of a construction plane (whose `name` is a `PlaneName`), flattened | `features::add_feature` |
| `PlaneName` | a construction plane's name, which may not be an origin plane's (strict names the command called; see its doc) | `features::check_plane_name` |
| `PlaneParam` | a plane: an origin or construction plane's name, a construction plane's id, or `{origin?, x_dir, y_dir}` / `{origin?, normal}` | `features::plane_param` |
| `PlaneOrFace` | a plane, or a planar face of a body `{face, body?, index?, path?, fixed?}` (offset and at-angle planes' `base`) | `features::plane_param`, `sketch::face::face_pick_form` |
| `GeoRefParam` | geometry a construction feature is built from: a picked face, edge or vertex, a sketch point or curve, a point, a plane, an axis, a construction point | `construct::geo_form` |
| `AxisLine` | an at-angle plane's `axis`: X, Y, Z, a construction axis, `{origin?, dir}` or a model edge or face | `features::axis_line_object`, `construct::geo_form` |
### When no shared type fits
@@ -274,7 +281,10 @@ Add one to `types.rs`, next to the reader it mirrors:
sentence, such as the handler's ``unknown operation `x` (…)``, in `whole(…)`, and the check
passes it on as it is. Otherwise start it with "must": the check names the key, so "must be a
point [x, y, z] of numbers" becomes ``"`corner` must be a point [x, y, z] of numbers"``. (Any
other message comes out as ``"`corner`: …"``.)
other message comes out as ``"`corner`: …"``.) Where the handler puts serde's own message
after the key (``"`point`: missing field `p`"``), wrap it in `keyed(…)`: the check puts the key
and a colon in front and leaves the rest as it is, instead of rewording "missing field" and
"invalid type" as it does for the type's own fields.
- Its `JsonSchema` describes the same values, with a description. Where JSON Schema can't say
something the reader checks (ignoring case, a byte length), say it in the description. A
pattern that allows surrounding whitespace must allow exactly what Rust's `trim` removes:
@@ -286,6 +296,11 @@ Add one to `types.rs`, next to the reader it mirrors:
`face_and_edge_refs_match_the_pick_readers`).
- A handler that reads a key more leniently than any type can (a wrong-typed value read as if
the key were absent) gets the stricter type: that difference is what warn and strict report.
- A reader that tries forms in order, where some forms, once chosen, refuse the value rather
than letting a later form take it (`construct::geo`: `{sketch, point: [1, 2, 3]}` is a
sketch reference without a point, never a fixed point), needs a schema that keeps that order:
give each later form a `not` naming the keys of the earlier forms that refuse, as
`GeoRefParam` does, and test values that two forms would read differently.
## Checklist