diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index bf95abfd..771d31a3 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -164,6 +164,39 @@ fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) { g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl)); } +/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an +/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters. +pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) { + let num = |e: &String| e.trim().parse::().ok().filter(|v| v.is_finite()); + if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] { + *p = crate::point_expr(apply_point(m, Vec3::new(x, y, z))); + return; + } + let old = p.clone(); + for (row, out) in p.iter_mut().enumerate() { + let mut constant = m[3][row]; + let mut terms: Vec = Vec::new(); + for (col, e) in old.iter().enumerate() { + let c = m[col][row]; + if c.abs() < 1e-12 { + continue; + } + match num(e) { + Some(v) => constant += c * v, + None if (c - 1.0).abs() < 1e-12 => terms.push(format!("({e})")), + None => terms.push(format!("{c} * ({e})")), + } + } + let mut e = terms.join(" + "); + if terms.is_empty() { + e = format!("{constant} mm"); + } else if constant.abs() >= 1e-12 { + e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs()); + } + *out = e; + } +} + impl FeatureKind { /// Map the feature's world geometry (points, directions, explicit planes) by `m`. pub fn transform_geometry(&mut self, m: &Mat) { @@ -207,12 +240,12 @@ impl FeatureKind { map_plane_ref(m, neutral); dir(pull); } - FeatureKind::Box { corner, .. } => pt(corner), + FeatureKind::Box { corner, .. } => map_point_expr(m, corner), FeatureKind::Cylinder { base, axis, .. } => { - pt(base); + map_point_expr(m, base); dir(axis); } - FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center), + FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center), FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => { dir(dir1); if let Some(d) = dir2 { @@ -259,7 +292,7 @@ impl FeatureKind { } FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {} FeatureKind::Coil { base, axis, .. } => { - pt(base); + map_point_expr(m, base); dir(axis); } FeatureKind::ReplaceFace { faces, target, .. } => { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2b879010..0fce5008 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -226,7 +226,8 @@ pub enum FeatureKind { body: Option, }, Box { - corner: Vec3, + #[serde(with = "point_expr")] + corner: PointExpr, length: String, width: String, height: String, @@ -234,7 +235,8 @@ pub enum FeatureKind { operation: Operation, }, Cylinder { - base: Vec3, + #[serde(with = "point_expr")] + base: PointExpr, #[serde(default = "z_axis")] axis: Vec3, radius: String, @@ -243,13 +245,15 @@ pub enum FeatureKind { operation: Operation, }, Sphere { - center: Vec3, + #[serde(with = "point_expr")] + center: PointExpr, radius: String, #[serde(default)] operation: Operation, }, Torus { - center: Vec3, + #[serde(with = "point_expr")] + center: PointExpr, major: String, minor: String, #[serde(default)] @@ -482,7 +486,8 @@ pub enum FeatureKind { /// A helical coil: a circular or square section swept `turns` times about `axis`, rising /// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside). Coil { - base: Vec3, + #[serde(with = "point_expr")] + base: PointExpr, #[serde(default = "z_axis")] axis: Vec3, diameter: String, @@ -939,6 +944,45 @@ fn z_axis() -> Vec3 { Vec3::Z } +/// A point whose coordinates are length expressions (`["basin_width / 2", "10 mm", "0"]`), so +/// it follows parameter changes. Primitives are placed by one. +pub type PointExpr = [String; 3]; + +/// A point as expressions (plain numbers, mm). +pub fn point_expr(p: Vec3) -> PointExpr { + [format!("{}", p.x), format!("{}", p.y), format!("{}", p.z)] +} + +/// Stored as `[x, y, z]`: plain numbers as numbers (as points were before they took +/// expressions, so such designs read and write the same), anything else as a string. +mod point_expr { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize, Deserialize)] + #[serde(untagged)] + enum Coord { + Num(f64), + Expr(String), + } + + pub fn serialize(p: &super::PointExpr, s: S) -> Result { + let c = |e: &String| match e.parse::() { + Ok(v) if v.is_finite() && format!("{v}") == *e => Coord::Num(v), + _ => Coord::Expr(e.clone()), + }; + [c(&p[0]), c(&p[1]), c(&p[2])].serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let c = |x: Coord| match x { + Coord::Num(v) => format!("{v}"), + Coord::Expr(e) => e, + }; + let [x, y, z] = <[Coord; 3]>::deserialize(d)?; + Ok([c(x), c(y), c(z)]) + } +} + impl FeatureKind { /// Sketches the feature is built from (profiles, paths, points): deleting one of them /// deletes the feature. Every kind is listed, so a new one has to say what it uses. @@ -1228,10 +1272,22 @@ impl FeatureKind { v.extend(distance2.iter().map(String::as_str)); v.extend(angle.iter().map(String::as_str)); } - FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]), - FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]), - FeatureKind::Sphere { radius, .. } => v.push(radius), - FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]), + FeatureKind::Box { length, width, height, corner, .. } => { + v.extend([length.as_str(), width, height]); + v.extend(corner.iter().map(String::as_str)); + } + FeatureKind::Cylinder { radius, height, base, .. } => { + v.extend([radius.as_str(), height]); + v.extend(base.iter().map(String::as_str)); + } + FeatureKind::Sphere { radius, center, .. } => { + v.push(radius); + v.extend(center.iter().map(String::as_str)); + } + FeatureKind::Torus { major, minor, center, .. } => { + v.extend([major.as_str(), minor]); + v.extend(center.iter().map(String::as_str)); + } FeatureKind::Combine { .. } => {} FeatureKind::Pattern { pattern, .. } => match pattern { PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => { @@ -1308,9 +1364,10 @@ impl FeatureKind { v.extend(gap.iter().map(String::as_str)); } FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {} - FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => { + FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => { v.extend([diameter.as_str(), pitch, turns, section_size]); v.extend(start_angle.iter().map(String::as_str)); + v.extend(base.iter().map(String::as_str)); } FeatureKind::Rib { thickness, depth, .. } => { v.push(thickness); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 8cd6a94d..b72539d2 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -684,6 +684,13 @@ pub(crate) const DESIGN_LEN: &str = "\u{1}design_len"; /// An expression's value. A length that comes out unit-less without being a bare number (a /// unit-less parameter used as a length) is in the design's units. +/// A point given as length expressions. +fn point(vals: &BTreeMap, p: &crate::PointExpr) -> Result { + let [x, y, z] = p; + let v = Vec3::new(val(vals, x, Kind::Length)?, val(vals, y, Kind::Length)?, val(vals, z, Kind::Length)?); + if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) } +} + fn val(vals: &BTreeMap, e: &str, k: Kind) -> Result { let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0); if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) { @@ -1537,6 +1544,7 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?]) } FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => { + let base = &point(vals, base)?; let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?; let (d, p, n, s) = ( val(vals, diameter, Kind::Length)?, @@ -1624,14 +1632,15 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - } FeatureKind::Box { corner, length, width, height, .. } => { let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); - Ok(vec![kernel::box_solid(*corner, *corner + s)?]) + let corner = point(vals, corner)?; + Ok(vec![kernel::box_solid(corner, corner + s)?]) } FeatureKind::Cylinder { base, axis, radius, height, .. } => { - Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?]) + Ok(vec![kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?]) } - FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]), + FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?]), FeatureKind::Torus { center, major, minor, .. } => { - Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]) + Ok(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]) } FeatureKind::Hole { diameter, depth, hole, to, .. } => { let (spots, dir) = hole_spots(st, f)?; @@ -2689,19 +2698,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } FeatureKind::Box { corner, length, width, height, operation } => { let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); - let b = kernel::box_solid(*corner, *corner + s)?; + let corner = point(vals, corner)?; + let b = kernel::box_solid(corner, corner + s)?; apply_op(st, f, vec![b], *operation, &[]) } FeatureKind::Cylinder { base, axis, radius, height, operation } => { - let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?; + let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?; apply_op(st, f, vec![b], *operation, &[]) } FeatureKind::Sphere { center, radius, operation } => { - let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?; + let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?; apply_op(st, f, vec![b], *operation, &[]) } FeatureKind::Torus { center, major, minor, operation } => { - let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?; + let b = kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?; apply_op(st, f, vec![b], *operation, &[]) } FeatureKind::Combine { target, tools, operation, keep_tools } => { @@ -2970,7 +2980,13 @@ mod chain_tests { fn tangent_chain_follows_smooth_edges() { let mut doc = Document::new("T"); doc.add_feature( - FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "20".into(), operation: Operation::NewBody }, + FeatureKind::Box { + corner: crate::point_expr(Vec3::ZERO), + length: "40".into(), + width: "30".into(), + height: "20".into(), + operation: Operation::NewBody, + }, None, ) .unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9f920212..f4b6cfc9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -25,7 +25,7 @@ mod project3d; pub use project3d::iso_grid; pub mod sheet; -pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; +pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid}; pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 5416b6cd..03d791de 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -276,6 +276,32 @@ impl FeatureKind { v } + /// A primitive's placement point (length expressions). Its coordinates are not inputs: + /// they get no parameter names and keep bare numbers in mm whatever the design's units, as + /// every other position does; renames and users still find them. + pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> { + match self { + FeatureKind::Box { corner: p, .. } + | FeatureKind::Cylinder { base: p, .. } + | FeatureKind::Sphere { center: p, .. } + | FeatureKind::Torus { center: p, .. } + | FeatureKind::Coil { base: p, .. } => Some(p), + _ => None, + } + } + + /// Read-only view of [`FeatureKind::point_expr_mut`]. + pub fn point_expr(&self) -> Option<&crate::PointExpr> { + match self { + FeatureKind::Box { corner: p, .. } + | FeatureKind::Cylinder { base: p, .. } + | FeatureKind::Sphere { center: p, .. } + | FeatureKind::Torus { center: p, .. } + | FeatureKind::Coil { base: p, .. } => Some(p), + _ => None, + } + } + /// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind). pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> { // Imports have no inputs (and their STEP text is large: don't clone it). @@ -655,6 +681,9 @@ impl Document { } } } + if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) { + out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() }); + } if let FeatureKind::Sketch { sketch, .. } = &f.kind { for c in &sketch.constraints { if c.param.as_deref() == Some(name) { @@ -718,6 +747,13 @@ impl Document { *x = new.to_string(); } } + for e in f.kind.point_expr_mut().into_iter().flatten() { + let r = expr::rename_reference(e, old, new); + if r != *e { + *e = r; + n += 1; + } + } for (_, e, _) in f.kind.inputs_mut() { let r = expr::rename_reference(e, old, new); if r != *e { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 5a4b9016..9c983111 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -165,7 +165,13 @@ fn errors_are_reported_not_fatal() { doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None) .unwrap(); doc.add_feature( - FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody }, + FeatureKind::Box { + corner: point_expr(Vec3::ZERO), + length: "nope".into(), + width: "1".into(), + height: "1".into(), + operation: Operation::NewBody, + }, None, ) .unwrap(); @@ -202,13 +208,19 @@ fn params_and_serialization() { fn primitives_and_combine() { let mut doc = Document::new("p"); doc.add_feature( - FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody }, + FeatureKind::Box { + corner: point_expr(Vec3::ZERO), + length: "10".into(), + width: "10".into(), + height: "10".into(), + operation: Operation::NewBody, + }, None, ) .unwrap(); doc.add_feature( FeatureKind::Cylinder { - base: Vec3::new(5.0, 5.0, 0.0), + base: point_expr(Vec3::new(5.0, 5.0, 0.0)), axis: Vec3::Z, radius: "2".into(), height: "20".into(), diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 60629d66..985d5db2 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1,7 +1,7 @@ //! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. use serde_json::{Value, json}; -use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, ProfileSel, expr::Kind}; +use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr}; use solvecraft_geom::Vec3; use super::CommandSpec; @@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("sweep") .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"), CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"), - CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"), - CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"), - CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"), - CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"), + CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"), + CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?"), + CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"), + CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"), CommandSpec::new("solid.fillet", "Fillet", fillet) .at("SOLID", "MODIFY") .icon("fillet") @@ -484,19 +484,59 @@ fn radius_expr(p: &Value, cmd: &str) -> Result { } } +/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions; +/// the origin when it is not given. Anything else is refused (never read as the origin). +pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + let want = || bad(cmd, format!("`{key}` must be a point [x, y, z] of numbers or length expressions")); + let coords: Vec<&Value> = match v { + Value::Array(a) if a.len() == 3 => a.iter().collect(), + Value::Object(o) => ["x", "y", "z"].iter().map(|k| o.get(*k)).collect::>>().ok_or_else(want)?, + _ => return Err(want()), + }; + let mut out = PointExpr::default(); + for (slot, (c, axis)) in out.iter_mut().zip(coords.into_iter().zip(["x", "y", "z"])) { + let e = expr(&json!({ "v": c }), "v").ok_or_else(want)?; + let x = s.doc.eval(&e, Kind::Length).map_err(|err| bad(cmd, format!("{key} {axis}: {err}")))?; + if !(x.is_finite() && x.abs() < 1e9) { + return Err(bad(cmd, format!("{key} {axis} is out of range"))); + } + *slot = e; + } + Ok(Some(out)) +} + +/// An axis direction: X|Y|Z or a non-zero `[x, y, z]`; `Z` when it is not given. +pub(super) fn axis_dir_param(p: &Value, cmd: &str) -> Result { + match p.get("axis") { + None => Ok(Vec3::Z), + Some(Value::String(a)) => match a.trim().to_ascii_uppercase().as_str() { + "X" => Ok(Vec3::X), + "Y" => Ok(Vec3::Y), + "Z" => Ok(Vec3::Z), + _ => Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")), + }, + Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]")), + } +} + fn prim_box(s: &mut Session, p: &Value) -> Result { let cmd = "solid.box"; let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?); for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] { check_expr(s, e, Kind::Length, cmd, n)?; } - let corner = match (p.get("corner").and_then(vec3), p.get("center").and_then(vec3)) { + let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) { (Some(c), _) => c, - (None, Some(c)) => { - let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0); - c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5 + // The corner half a size back from the centre (numbers stay numbers). + (None, Some([cx, cy, cz])) => { + let back = |c: String, size: &String| match (c.parse::(), size.parse::()) { + (Ok(a), Ok(b)) => format!("{}", a - b / 2.0), + _ => format!("({c}) - ({size}) / 2"), + }; + [back(cx, &l), back(cy, &w), back(cz, &h)] } - _ => Vec3::ZERO, + _ => point_expr(Vec3::ZERO), }; add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? }) } @@ -507,8 +547,8 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result { let height = req_expr(cmd, p, "height")?; check_expr(s, &radius, Kind::Length, cmd, "radius")?; check_expr(s, &height, Kind::Length, cmd, "height")?; - let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); - let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); + let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO)); + let axis = axis_dir_param(p, cmd)?; add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? }) } @@ -516,7 +556,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result { let cmd = "solid.sphere"; let radius = radius_expr(p, cmd)?; check_expr(s, &radius, Kind::Length, cmd, "radius")?; - let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO); + let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO)); add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? }) } @@ -525,7 +565,7 @@ fn prim_torus(s: &mut Session, p: &Value) -> Result { let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?); check_expr(s, &major, Kind::Length, cmd, "major")?; check_expr(s, &minor, Kind::Length, cmd, "minor")?; - let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO); + let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO)); add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? }) } diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index 5172d25a..d98a6c23 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -6,7 +6,7 @@ use solvecraft_doc::expr::Kind; use solvecraft_geom::Vec3; use super::CommandSpec; -use super::features::{add_feature, check_expr, feature_sketch, plane_param, profiles}; +use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles}; use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; use crate::{Result, Session}; @@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params( "diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \ - base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?", + base?: [x,y,z] (numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?", ), CommandSpec::new("solid.rib", "Rib", rib) .at("SOLID", "CREATE") @@ -43,17 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[ fn coil(s: &mut Session, p: &Value) -> Result { let cmd = "solid.coil"; - let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); - let axis = match p.get("axis") { - None => Vec3::Z, - Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() { - "X" => Vec3::X, - "Y" => Vec3::Y, - "Z" => Vec3::Z, - _ => return Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")), - }, - Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]"))?, - }; + let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| solvecraft_doc::point_expr(Vec3::ZERO)); + let axis = axis_dir_param(p, cmd)?; let diameter = req_expr(cmd, p, "diameter")?; check_expr(s, &diameter, Kind::Length, cmd, "diameter")?; let turns = expr(p, "revolutions").or_else(|| expr(p, "turns")); diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index b089af96..dd0ab8fe 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -10,7 +10,7 @@ //! without moved components). use serde_json::{Map, Value}; -use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse}; +use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse}; use solvecraft_geom::Vec3; use crate::Session; @@ -203,11 +203,29 @@ impl Mapper<'_> { } fn make(&mut self, v: &Value) -> Option { - let p = vec3(v)?; let m = self.f.active; + let Some(p) = vec3(v) else { return self.make_expr(&m, v) }; self.local_point(&m, p) } + /// A point with expression coordinates (`["w / 2", 0, 0]`): mapped as expressions. + fn make_expr(&mut self, m: &Mat, v: &Value) -> Option { + let a = v.as_array().filter(|a| a.len() == 3)?; + let c = |i: usize| match a.get(i)? { + Value::Number(n) => n.as_f64().filter(|x| x.is_finite()).map(|x| format!("{x}")), + Value::String(e) => Some(e.clone()), + _ => None, + }; + let mut p = [c(0)?, c(1)?, c(2)?]; + if is_identity(m) { + return None; + } + let inv = mat_inverse(m)?; + map_point_expr(&inv, &mut p); + self.changed = true; + Some(serde_json::json!(p)) + } + fn dir(&mut self, v: &Value) -> Option { let d = vec3(v)?; let m = self.first.unwrap_or(self.f.active); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2495c83c..944d6fbf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,6 +36,7 @@ fn join_extrude_from_a_chamfered_top_face() { } mod component_frames; +mod primitive_points; mod robustness; /// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. diff --git a/crates/engine/src/tests/primitive_points.rs b/crates/engine/src/tests/primitive_points.rs new file mode 100644 index 00000000..1c9b9e0a --- /dev/null +++ b/crates/engine/src/tests/primitive_points.rs @@ -0,0 +1,121 @@ +//! Primitive placement points (`base`, `corner`, `center`) take expressions like their sizes do, +//! follow parameter changes, and are refused when they cannot be read (user report: a cylinder +//! with `base: ["basin_width/2", …]` silently landed at the origin). + +use serde_json::{Value, json}; + +use super::run; +use crate::*; + +fn bbox(s: &Session, body: &str) -> ([f64; 3], [f64; 3]) { + let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap(); + let m = solvecraft_kernel::measure(&b).unwrap(); + ([m.bbox.min.x, m.bbox.min.y, m.bbox.min.z], [m.bbox.max.x, m.bbox.max.y, m.bbox.max.z]) +} + +fn near(a: [f64; 3], b: [f64; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3) +} + +#[test] +fn cylinder_base_expressions_follow_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "100 mm"})); + run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": ["basin_width/2", "10 mm", "-2"], "body_name": "Pin"})); + let (lo, hi) = bbox(&s, "Pin"); + assert!(near(lo, [45.0, 5.0, -2.0]) && near(hi, [55.0, 15.0, 8.0]), "{lo:?} {hi:?}"); + // A parameter change moves it on recompute. + run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "60 mm"})); + let (lo, _) = bbox(&s, "Pin"); + assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}"); + // And it survives a save and reopen. + let text = s.doc.to_json(); + assert!(text.contains("basin_width/2"), "{text}"); + let s2 = Session::new(solvecraft_doc::Document::from_json(&text).unwrap()); + let (lo, _) = bbox(&s2, "Pin"); + assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}"); +} + +#[test] +fn primitive_points_take_expressions() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "w", "expression": "40 mm"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": ["w", 0, 0], "body_name": "A"})); + run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 10, "center": [0, "w", 0], "body_name": "B"})); + run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["w * 2", 0, 0], "body_name": "C"})); + run(&mut s, "solid.torus", json!({"major": 5, "minor": 1, "center": [0, 0, "w"], "body_name": "D"})); + run(&mut s, "solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": ["-w", 0, 0], "body_name": "E"})); + assert!(near(bbox(&s, "A").0, [40.0, 0.0, 0.0])); + assert!(near(bbox(&s, "B").0, [-20.0, 35.0, -5.0]), "{:?}", bbox(&s, "B")); + assert!(near(bbox(&s, "C").0, [78.0, -2.0, -2.0])); + assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 39.0])); + assert!((bbox(&s, "E").0[0] + 45.5).abs() < 0.2, "{:?}", bbox(&s, "E")); + run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"})); + assert!(near(bbox(&s, "A").0, [20.0, 0.0, 0.0])); + assert!(near(bbox(&s, "B").0, [-10.0, 15.0, -5.0]), "{:?}", bbox(&s, "B")); + assert!(near(bbox(&s, "C").0, [38.0, -2.0, -2.0])); + assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 19.0])); + assert!((bbox(&s, "E").0[0] + 25.5).abs() < 0.2, "{:?}", bbox(&s, "E")); +} + +/// Points and axes that cannot be read are refused, never silently put at the origin. +#[test] +fn unreadable_primitive_points_are_refused() { + let mut s = Session::default(); + let cases: Vec<(&str, Value)> = vec![ + ("solid.cylinder", json!({"radius": 5, "height": 10, "base": ["nope/2", 0, 0]})), + ("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2]})), + ("solid.cylinder", json!({"radius": 5, "height": 10, "base": "XY"})), + ("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2, "5 deg"]})), + ("solid.cylinder", json!({"radius": 5, "height": 10, "axis": ["x", 0, 1]})), + ("solid.cylinder", json!({"radius": 5, "height": 10, "axis": [0, 0, 0]})), + ("solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, null, 0]})), + ("solid.box", json!({"length": 1, "width": 1, "height": 1, "center": {"x": 1}})), + ("solid.sphere", json!({"radius": 1, "center": ["a +", 0, 0]})), + ("solid.torus", json!({"major": 5, "minor": 1, "center": [1e300, 0, 0]})), + ("solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": [true, 0, 0]})), + ]; + for (cmd, p) in cases { + let before = s.doc.features.len(); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} should be refused"); + assert_eq!(s.doc.features.len(), before, "{cmd} {p} added a feature"); + } +} + +/// Renaming a parameter follows into placement points; deleting it is refused while a point +/// uses it. Plain-number points are stored as numbers, as before. +#[test] +fn point_parameters_rename_and_store() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "off", "expression": "30 mm"})); + run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["off", 0, 0], "body_name": "S"})); + run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [1.5, -2, 0], "body_name": "B"})); + assert!(s.execute("parameters.delete", &json!({"name": "off"})).is_err()); + run(&mut s, "parameters.rename", json!({"name": "off", "new_name": "shift"})); + run(&mut s, "parameters.change", json!({"name": "shift", "expression": "10 mm"})); + assert!(near(bbox(&s, "S").0, [8.0, -2.0, -2.0]), "{:?}", bbox(&s, "S")); + let v: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + let feats = v["features"].as_array().unwrap(); + assert_eq!(feats[0]["center"], json!(["shift", 0.0, 0.0]), "{v}"); + assert_eq!(feats[1]["corner"], json!([1.5, -2.0, 0.0]), "{v}"); +} + +/// A primitive placed by an expression in a moved component lands in that component's frame +/// and keeps following its parameter. +#[test] +fn point_expressions_in_a_moved_component() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "x0", "expression": "10 mm"})); + let c = run(&mut s, "component.create", json!({"name": "Arm"})); + let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]})); + run(&mut s, "component.activate", json!({"component": cid})); + run(&mut s, "solid.cylinder", json!({"radius": 1, "height": 2, "base": ["x0", 60, 0], "body_name": "Pin"})); + let wlo = |s: &Session| s.world_state().body("Pin").unwrap().mesh().bounds().min; + let lo = wlo(&s); + assert!((lo.x - 9.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}"); + run(&mut s, "parameters.change", json!({"name": "x0", "expression": "20 mm"})); + let lo = wlo(&s); + assert!((lo.x - 19.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}"); +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 7ed5dcb0..fe66049d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -425,13 +425,19 @@ pub const MAX_STL_TRIANGLES: usize = 20_000_000; #[cfg(test)] mod tests { use super::*; - use solvecraft_doc::{FeatureKind, Model, Operation}; + use solvecraft_doc::{FeatureKind, Model, Operation, point_expr}; use solvecraft_geom::Vec3; fn model() -> (Document, Model) { let mut doc = Document::new("t"); doc.add_feature( - FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "20".into(), height: "30".into(), operation: Operation::NewBody }, + FeatureKind::Box { + corner: point_expr(Vec3::ZERO), + length: "10".into(), + width: "20".into(), + height: "30".into(), + operation: Operation::NewBody, + }, None, ) .unwrap(); @@ -558,7 +564,13 @@ mod tests { fn threemf_round_trip() { let mut doc = Document::new("t"); doc.add_feature( - FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "10".into(), operation: Operation::NewBody }, + FeatureKind::Box { + corner: point_expr(Vec3::ZERO), + length: "40".into(), + width: "30".into(), + height: "10".into(), + operation: Operation::NewBody, + }, None, ) .unwrap(); @@ -569,7 +581,7 @@ mod tests { .unwrap(); doc.add_feature( FeatureKind::Cylinder { - base: Vec3::new(80.0, 0.0, 0.0), + base: point_expr(Vec3::new(80.0, 0.0, 0.0)), axis: Vec3::Z, radius: "6".into(), height: "15".into(), @@ -579,7 +591,12 @@ mod tests { ) .unwrap(); doc.add_feature( - FeatureKind::Torus { center: Vec3::new(0.0, 80.0, 0.0), major: "12".into(), minor: "3".into(), operation: Operation::NewBody }, + FeatureKind::Torus { + center: point_expr(Vec3::new(0.0, 80.0, 0.0)), + major: "12".into(), + minor: "3".into(), + operation: Operation::NewBody, + }, None, ) .unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 27912c30..75476dbd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2801,6 +2801,11 @@ fn pt3(v: Vec3) -> Value { json!([v.x, v.y, v.z]) } +/// A point of expressions: numbers as numbers, expressions as strings. +fn pt_expr(p: &solvecraft_engine::doc::PointExpr) -> Value { + Value::Array(p.iter().map(|e| e.parse::().ok().filter(|x| x.is_finite()).map_or_else(|| json!(e), |x| json!(x))).collect()) +} + /// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just /// before the feature, so its references show on the geometry they refer to. pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { @@ -3008,7 +3013,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { @@ -3018,14 +3023,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { let mut d = start("solid.sphere")?; d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) }; - d.extra.insert("center".into(), pt3(*center)); + d.extra.insert("center".into(), pt_expr(center)); d } FeatureKind::Torus { center, major, minor, operation } => { @@ -3035,7 +3040,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option {