From 991082fbaf62337605d4648d2ac6735cd84e05a4 Mon Sep 17 00:00:00 2001 From: Matthew Knutson Date: Fri, 30 Oct 2026 06:03:04 +0000 Subject: [PATCH] Typed contracts for the construction axes, points and planes (#2829) (#7750) * claim: #2829 (construct.* contracts) Co-Authored-By: Claude Opus 5.5 * 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 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/construct.rs | 5 +- crates/engine/src/cmd/construct.rs | 248 +++++++++++---- crates/engine/src/cmd/features.rs | 71 +++-- crates/engine/src/cmd/mod.rs | 4 +- crates/engine/src/cmd/sketch_face.rs | 60 ++-- crates/engine/src/schema/check.rs | 13 + crates/engine/src/schema/construct.rs | 239 +++++++++++++++ crates/engine/src/schema/construct_tests.rs | 133 ++++++++ crates/engine/src/schema/features.rs | 41 ++- crates/engine/src/schema/features_tests.rs | 23 +- crates/engine/src/schema/mod.rs | 1 + crates/engine/src/schema/tests.rs | 174 +++++++++++ crates/engine/src/schema/types.rs | 318 +++++++++++++++++++- crates/engine/src/schema/unconverted.txt | 19 -- docs/book/src/dev/schemas.md | 19 +- 15 files changed, 1234 insertions(+), 134 deletions(-) create mode 100644 crates/engine/src/schema/construct.rs create mode 100644 crates/engine/src/schema/construct_tests.rs diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs index 4ab6a9783a..9d7ba98f0b 100644 --- a/crates/doc/src/construct.rs +++ b/crates/doc/src/construct.rs @@ -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 diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs index b815b94aa5..400a21fb05 100644 --- a/crates/engine/src/cmd/construct.rs +++ b/crates/engine/src/cmd/construct.rs @@ -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 = TypedContract::new(); +const AXIS_NORMAL_TO_FACE_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_TWO_PLANES_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_TWO_POINTS_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_EDGE_CONTRACT: TypedContract = TypedContract::new(); +const POINT_VERTEX_CONTRACT: TypedContract = TypedContract::new(); +const POINT_TWO_EDGES_CONTRACT: TypedContract = TypedContract::new(); +const POINT_THREE_PLANES_CONTRACT: TypedContract = TypedContract::new(); +const POINT_CENTER_CONTRACT: TypedContract = TypedContract::new(); +const POINT_EDGE_PLANE_CONTRACT: TypedContract = TypedContract::new(); +const POINT_ALONG_PATH_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_MIDPLANE_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_TWO_EDGES_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_THREE_POINTS_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_TANGENT_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_ALONG_PATH_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_PERPENDICULAR_CONTRACT: TypedContract = 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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) .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::) + .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> { - // 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>, + }, + 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, GeoFormError> { + let p3 = |k: &str| v.get(k).and_then(vec3); + Ok(if v.get("type").is_some() { + GeoForm::Saved(serde_json::from_value::(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 { 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::(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) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index db3d5732a0..a9b53ef0b5 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 = TypedContract::new(); const PIPE_CONTRACT: TypedContract = TypedContract::new(); const BOUNDING_SOLID_CONTRACT: TypedContract = TypedContract::new(); const SCALE_CONTRACT: TypedContract = TypedContract::new(); +const OFFSET_PLANE_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_AT_ANGLE_CONTRACT: TypedContract = 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::) .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::) .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 { + 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 { + 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 { 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 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 }); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6e9f0689b7..e5750d2039 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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}; diff --git a/crates/engine/src/cmd/sketch_face.rs b/crates/engine/src/cmd/sketch_face.rs index d8fa720a4b..2742b8ada6 100644 --- a/crates/engine/src/cmd/sketch_face.rs +++ b/crates/engine/src/cmd/sketch_face.rs @@ -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 { +/// 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, + /// The occurrence path of the body's placement. + pub path: Option>, +} + +/// 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, &'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::>>()?, + .map(|id| id.as_u64().filter(|id| *id != 0).ok_or("path ids must be nonzero integers")) + .collect::, _>>()?, ), }; + Ok(FacePickForm { point, fixed, body, index, path }) +} + +fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result { + 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)))); diff --git a/crates/engine/src/schema/check.rs b/crates/engine/src/schema/check.rs index fa2ff6d7bc..e9a11e3c35 100644 --- a/crates/engine/src/schema/check.rs +++ b/crates/engine/src/schema/check.rs @@ -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) -> 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`). diff --git a/crates/engine/src/schema/construct.rs b/crates/engine/src/schema/construct.rs new file mode 100644 index 0000000000..edd0917358 --- /dev/null +++ b/crates/engine/src/schema/construct.rs @@ -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, + #[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, + #[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, + #[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; diff --git a/crates/engine/src/schema/construct_tests.rs b/crates/engine/src/schema/construct_tests.rs new file mode 100644 index 0000000000..f461b6ef6c --- /dev/null +++ b/crates/engine/src/schema/construct_tests.rs @@ -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::(), &["face", "name", "participants"]); + assert_keys(input_schema_for::(), &["face", "name", "participants", "point"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["edge", "name", "participants"]); + assert_keys(input_schema_for::(), &["name", "participants", "point"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "c", "name", "participants"]); + assert_keys(input_schema_for::(), &["name", "of", "participants"]); + assert_keys(input_schema_for::(), &["edge", "name", "participants", "plane"]); + assert_keys(input_schema_for::(), &["name", "participants", "path", "t"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "c", "name", "participants"]); + assert_keys(input_schema_for::(), &["at", "face", "name", "participants"]); + assert_keys(input_schema_for::(), &["name", "participants", "path", "t"]); + assert_keys(input_schema_for::(), &["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::(); + 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())); +} diff --git a/crates/engine/src/schema/features.rs b/crates/engine/src/schema/features.rs index 7ca08762b5..d524553d87 100644 --- a/crates/engine/src/schema/features.rs +++ b/crates/engine/src/schema/features.rs @@ -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, } +/// 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, + /// 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, + #[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 { diff --git a/crates/engine/src/schema/features_tests.rs b/crates/engine/src/schema/features_tests.rs index 05d5255d45..09bfb432f3 100644 --- a/crates/engine/src/schema/features_tests.rs +++ b/crates/engine/src/schema/features_tests.rs @@ -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::(), &["bodies", "body_name", "body_names", "margin", "name", "participants"]); assert_keys(input_schema_for::(), &["bodies", "factor", "factors", "name", "origin", "participants"]); + assert_keys(input_schema_for::(), &["base", "name", "offset", "participants", "through"]); + assert_keys(input_schema_for::(), &["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})), ]); } diff --git a/crates/engine/src/schema/mod.rs b/crates/engine/src/schema/mod.rs index 9211bd71a9..cbbe5d87c9 100644 --- a/crates/engine/src/schema/mod.rs +++ b/crates/engine/src/schema/mod.rs @@ -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; diff --git a/crates/engine/src/schema/tests.rs b/crates/engine/src/schema/tests.rs index 3d5a3f00fa..966478d3de 100644 --- a/crates/engine/src/schema/tests.rs +++ b/crates/engine/src/schema/tests.rs @@ -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::(); + 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::(plane.clone()).is_some(), + form => form.is_ok(), + }; + assert_eq!(form, accepted, "reader {v}"); + assert_eq!(parse::(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::(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::(); + let plane = plane.as_value(); + let base = input_schema_for::(); + 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::(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::(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::(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::(json!(7.0)).is_none() && parse::(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::(); + 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::(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::(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::(json!(name)).is_none(), refused.is_some(), "{name:?}"); + } + assert!(parse::(json!(5)).is_none()); +} diff --git a/crates/engine/src/schema/types.rs b/crates/engine/src/schema/types.rs index 7d0bd4521a..43c9402f97 100644 --- a/crates/engine/src/schema/types.rs +++ b/crates/engine/src/schema/types.rs @@ -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 { + 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: D) -> Result { + 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::(); + let origin = generator.subschema_for::(); + 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 { + 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: D) -> Result { + 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::(); + let plane = generator.subschema_for::(); + let sketch = generator.subschema_for::(); + 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::>()}); + 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: D) -> Result { + 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::(); + let plane = generator.subschema_for::(); + 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: D) -> Result { + 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::(); + 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: D) -> Result { + 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, + /// 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, +} + +/// [`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, + /// 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, +} + /// 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, } -/// 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 { +/// 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 { let v = Value::deserialize(d)?; serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e))) } diff --git a/crates/engine/src/schema/unconverted.txt b/crates/engine/src/schema/unconverted.txt index 20c8f7fdfb..bc165ff770 100644 --- a/crates/engine/src/schema/unconverted.txt +++ b/crates/engine/src/schema/unconverted.txt @@ -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 diff --git a/docs/book/src/dev/schemas.md b/docs/book/src/dev/schemas.md index 3d3106f278..70dd3d7f7e 100644 --- a/docs/book/src/dev/schemas.md +++ b/docs/book/src/dev/schemas.md @@ -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