From dfa9e094cd5ed3e08782f087dbee69324e0f5283 Mon Sep 17 00:00:00 2001 From: semmelrinde <124386289+semmelrinde@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:35:25 +0200 Subject: [PATCH] fix: a size worked out from a bare diameter keeps its unit when the design's units change (#716) (#779) A size typed as a bare diameter now keeps the design's length unit, so it no longer changes when the design's units change. Fixes #716. --- crates/doc/src/expr.rs | 13 ++ crates/doc/src/params.rs | 2 +- crates/engine/src/cmd/features.rs | 54 +++++- crates/engine/src/cmd/features_more.rs | 16 +- crates/engine/src/derived_units_tests.rs | 230 +++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + docs/book/src/guide/parameters.md | 6 +- 7 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/derived_units_tests.rs diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index e1efc327..0d54eb10 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -350,6 +350,17 @@ pub fn is_literal(s: &str) -> bool { } } +/// Is the expression a bare number without a unit, like `20` or `-1.5`? +pub fn is_bare_number(s: &str) -> bool { + is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::().is_ok() +} + +/// The length unit of a design with these units: `mm` unless they name another length unit. +pub fn length_unit_or_mm(units: &str) -> &str { + let u = units.trim(); + if unit_info(u).is_some_and(|(k, _)| k == Kind::Length) { u } else { "mm" } +} + struct Parser<'a> { toks: Vec, pos: usize, @@ -942,5 +953,7 @@ mod tests { assert_eq!(rename_reference("10 mm", "mm", "x"), "10 mm"); assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg")); assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width")); + assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)")); + assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm")); } } diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index b8270056..1098ef99 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -466,7 +466,7 @@ impl Document { /// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged. pub fn with_design_unit(&self, e: &str, kind: Kind) -> String { let u = self.units.trim(); - let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::().is_ok(); + let bare = expr::is_bare_number(e); if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) { format!("{} {u}", e.trim()) } else { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 27e914a6..0bcf62e7 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -616,14 +616,36 @@ fn revolve(s: &mut Session, p: &Value) -> Result { add_feature(s, p, kind) } -fn radius_expr(p: &Value, cmd: &str) -> Result { +fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result { match (expr(p, "radius"), expr(p, "diameter")) { (Some(r), _) => Ok(r), - (None, Some(d)) => Ok(format!("({d}) / 2")), + (None, Some(d)) => { + let [d] = length_operands(s, [(&d, &typed_length_unit(s))]); + Ok(format!("({d}) / 2")) + } _ => Err(bad(cmd, "needs `radius` or `diameter`")), } } +/// The unit a bare length typed into a command means: the design's units. (A point's bare +/// coordinates, and a bare number already stored in a feature, are millimetres.) +pub(super) fn typed_length_unit(s: &Session) -> String { + solidcraft_doc::expr::length_unit_or_mm(&s.doc.units).to_string() +} + +/// The lengths a command works one expression out from (a radius from a diameter, a pitch from +/// a height), each with the unit a bare number means where it came from. Bare numbers get that +/// unit written in, so the expression keeps the meaning they had: inside a larger expression a +/// bare number is read in the design's units of the moment, and changing them would resize the +/// part (#716). Next to a unit-less expression that is not a bare number (`2 * 5`, a unit-less +/// parameter), which is read in the design's units itself, they stay bare and are read with it, +/// as before. +pub(super) fn length_operands(s: &Session, operands: [(&str, &str); N]) -> [String; N] { + use solidcraft_doc::expr::is_bare_number; + let follows = operands.iter().any(|(e, _)| !is_bare_number(e) && s.doc.eval(e, Kind::Unitless).is_ok()); + operands.map(|(e, unit)| if !follows && is_bare_number(e) { format!("{} {unit}", e.trim()) } else { e.to_string() }) +} + /// 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> { @@ -668,11 +690,17 @@ fn prim_box(s: &mut Session, p: &Value) -> Result { } let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) { (Some(c), _) => c, - // The corner half a size back from the centre (numbers stay numbers). + // The corner half a size back from the centre (numbers stay numbers, in mm). The centre's + // bare numbers are millimetres, the sizes' the design's units. (None, Some([cx, cy, cz])) => { + let unit = typed_length_unit(s); + let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k); let back = |c: String, size: &String| match (c.parse::(), size.parse::()) { - (Ok(a), Ok(b)) => format!("{}", a - b / 2.0), - _ => format!("({c}) - ({size}) / 2"), + (Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0), + _ => { + let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]); + format!("({c}) - ({size}) / 2") + } }; [back(cx, &l), back(cy, &w), back(cz, &h)] } @@ -683,7 +711,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result { fn prim_cylinder(s: &mut Session, p: &Value) -> Result { let cmd = "solid.cylinder"; - let radius = radius_expr(p, cmd)?; + let radius = radius_expr(s, p, cmd)?; let height = req_expr(cmd, p, "height")?; check_expr(s, &radius, Kind::Length, cmd, "radius")?; check_expr(s, &height, Kind::Length, cmd, "height")?; @@ -694,7 +722,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result { fn prim_sphere(s: &mut Session, p: &Value) -> Result { let cmd = "solid.sphere"; - let radius = radius_expr(p, cmd)?; + let radius = radius_expr(s, p, cmd)?; check_expr(s, &radius, Kind::Length, cmd, "radius")?; 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)? }) @@ -758,7 +786,9 @@ fn press_pull(s: &mut Session, p: &Value) -> Result { FeatureKind::Chamfer { distance, .. } => ("distance", distance.clone()), _ => return Err(bad(cmd, "that face is not a fillet's or a chamfer's")), }; - let new = format!("({old}) + ({distance})"); + // A bare number stored in the feature is millimetres; the distance's, the design's units. + let [old, more] = length_operands(s, [(&old, "mm"), (&distance, &typed_length_unit(s))]); + let new = format!("({old}) + ({more})"); let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?; let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?; return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")})); @@ -767,7 +797,13 @@ fn press_pull(s: &mut Session, p: &Value) -> Result { let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?; return (r.run)(s, &json!({"faces": [face], "distance": distance})); } - let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") }; + // Pushed in: the extrude cuts the other way by as much (a bare number stays one, so it keeps + // being read as a bare number typed into the extrude). + let flipped = match distance.trim().parse::() { + Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => format!("{}", -v), + _ => format!("-({distance})"), + }; + let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (flipped, "negative", "cut") }; extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op})) } diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index d7985102..eac441ba 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -6,7 +6,9 @@ use solidcraft_doc::expr::Kind; use solidcraft_geom::Vec3; use super::CommandSpec; -use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles}; +use super::features::{ + add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit, +}; use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; use crate::{Result, Session}; @@ -49,10 +51,18 @@ fn coil(s: &mut Session, p: &Value) -> Result { check_expr(s, &diameter, Kind::Length, cmd, "diameter")?; let turns = expr(p, "revolutions").or_else(|| expr(p, "turns")); let (height, pitch) = (expr(p, "height"), expr(p, "pitch")); + // The pitch or the revolutions from the height keep the units the lengths were given in. + let unit = typed_length_unit(s); let (turns, pitch) = match (turns, height, pitch) { (Some(n), _, Some(pt)) => (n, pt), - (Some(n), Some(h), None) => (n.clone(), format!("({h}) / ({n})")), - (None, Some(h), Some(pt)) => (format!("({h}) / ({pt})"), pt), + (Some(n), Some(h), None) => { + let [h] = length_operands(s, [(&h, &unit)]); + (n.clone(), format!("({h}) / ({n})")) + } + (None, Some(h), Some(pt)) => { + let [h, by] = length_operands(s, [(&h, &unit), (&pt, &unit)]); + (format!("({h}) / ({by})"), pt) + } _ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")), }; check_expr(s, &turns, Kind::Unitless, cmd, "revolutions")?; diff --git a/crates/engine/src/derived_units_tests.rs b/crates/engine/src/derived_units_tests.rs new file mode 100644 index 00000000..9da2efaf --- /dev/null +++ b/crates/engine/src/derived_units_tests.rs @@ -0,0 +1,230 @@ +//! Sizes a command works out from what it was given keep the meaning those inputs had when the +//! design's units change (#716): a sphere or a cylinder made with a bare `diameter`, a coil's +//! pitch or revolutions from its height, a box placed by its centre, a Press Pull. A radius from +//! a bare diameter used to be stored as `(20) / 2`: a unit-less expression that is not a bare +//! number is read in the design's units of the moment, so switching a millimetre design to +//! inches made such a sphere 25.4 times larger. + +use std::f64::consts::PI; + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +/// A body's volume (mm³) and bounding box (min x, y, z, max x, y, z in mm). +#[derive(Debug, Clone, Copy)] +struct Shape { + volume: f64, + bbox: [f64; 6], +} + +impl Shape { + fn size(&self, axis: usize) -> f64 { + self.bbox[axis + 3] - self.bbox[axis] + } + fn same(&self, other: &Shape) -> bool { + rel(self.volume, other.volume) < 1e-9 && self.bbox.iter().zip(&other.bbox).all(|(a, b)| (a - b).abs() < 1e-7) + } +} + +/// Every body by name. +fn shapes(s: &mut Session) -> Vec<(String, Shape)> { + measured(s, json!({})) +} + +fn measured(s: &mut Session, p: Value) -> Vec<(String, Shape)> { + let m = run(s, "inspect.measure", p); + let bodies = m["bodies"].as_array().cloned().unwrap_or_default(); + bodies + .iter() + .map(|b| { + let c = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN); + let bbox = [c("min", 0), c("min", 1), c("min", 2), c("max", 0), c("max", 1), c("max", 2)]; + (b["name"].as_str().unwrap_or_default().to_string(), Shape { volume: b["volume_mm3"].as_f64().unwrap_or(f64::NAN), bbox }) + }) + .collect() +} + +/// One body, measured alone. +fn shape(s: &mut Session, body: &str) -> Shape { + measured(s, json!({ "bodies": [body] })).into_iter().next().map(|(_, x)| x).unwrap_or_else(|| panic!("no body {body}")) +} + +/// Switch the design to each unit in turn; every body keeps its volume and its bounding box. +fn keeps_shape_through(s: &mut Session, units: &[&str]) { + let before = shapes(s); + assert!(!before.is_empty()); + for u in units { + let r = run(s, "document.units", json!({ "units": u })); + let now = shapes(s); + assert_eq!(now.len(), before.len(), "{u}: {r}"); + for ((name, a), (_, b)) in before.iter().zip(&now) { + assert!(a.same(b), "{name} after switching to {u} ({r}): {a:?} became {b:?}"); + } + } +} + +/// The expressions of a feature's inputs. +fn inputs(s: &Session, feature: usize) -> Vec { + s.doc.features.get(feature).map(|f| f.kind.inputs().into_iter().map(|(_, e, _)| e).collect()).unwrap_or_default() +} + +#[test] +fn bare_diameters_keep_their_size_when_the_units_change() { + let mut s = Session::default(); + run(&mut s, "solid.sphere", json!({"diameter": 20, "body_name": "Ball"})); + run(&mut s, "solid.cylinder", json!({"diameter": 20, "height": 10, "base": [50, 0, 0], "body_name": "Can"})); + let ball = shape(&mut s, "Ball"); + assert!(rel(ball.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{ball:?}"); + // The bounding box comes from the tessellation: a little inside the surface. + assert!((ball.size(0) - 20.0).abs() < 1e-3 && (ball.bbox[0] + 10.0).abs() < 1e-3, "{ball:?}"); + let can = shape(&mut s, "Can"); + assert!(rel(can.volume, PI * 100.0 * 10.0) < 1e-3, "{can:?}"); + assert!((can.bbox[0] - 40.0).abs() < 1e-3 && (can.bbox[3] - 60.0).abs() < 1e-3 && (can.size(2) - 10.0).abs() < 1e-6, "{can:?}"); + keeps_shape_through(&mut s, &["in", "cm", "ft", "mm", "in"]); + // The radius says what the diameter was, with the unit it was typed in. + assert_eq!(inputs(&s, 0), ["(20 mm) / 2"]); + assert_eq!(inputs(&s, 1), ["(20 mm) / 2", "10"]); +} + +#[test] +fn bare_diameters_typed_in_an_inch_design_mean_inches() { + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + run(&mut s, "solid.sphere", json!({"diameter": 2, "body_name": "Ball"})); + // The base is a point: its bare numbers are millimetres. + run(&mut s, "solid.cylinder", json!({"diameter": 1, "height": 2, "base": [100, 0, 0], "body_name": "Can"})); + let ball = shape(&mut s, "Ball"); + assert!(rel(ball.volume, 4.0 / 3.0 * PI * 25.4f64.powi(3)) < 1e-3, "{ball:?}"); + assert!((ball.size(0) - 50.8).abs() < 1e-3, "{ball:?}"); + let can = shape(&mut s, "Can"); + assert!(rel(can.volume, PI * 12.7 * 12.7 * 50.8) < 1e-3, "{can:?}"); + assert!((can.bbox[0] - 87.3).abs() < 1e-3 && (can.size(2) - 50.8).abs() < 1e-6, "{can:?}"); + keeps_shape_through(&mut s, &["mm", "cm", "in"]); +} + +/// A body's extent: the diagonal of its vertices' bounding box. Measuring a coil takes seconds; +/// this does not. +fn extent(s: &Session, body: &str) -> f64 { + s.model.state().body(body).map_or(f64::NAN, |b| b.body.size()) +} + +#[test] +fn coil_pitch_and_revolutions_keep_their_size_when_the_units_change() { + use std::f64::consts::TAU; + let mut s = Session::default(); + // Height and revolutions give the pitch. + run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "height": 16, "section_size": 4, "body_name": "A"})); + // Height and pitch give the revolutions: a height with its unit next to a bare pitch, both + // millimetres here. + run(&mut s, "solid.coil", json!({"diameter": 40, "height": "16 mm", "pitch": 8, "section_size": 4, "base": [100, 0, 0], "body_name": "B"})); + let made = shapes(&mut s); + let want = PI * 4.0 * TAU * 20.0 * 2.0; + for (body, x) in &made { + assert!(rel(x.volume, want) < 1e-3, "{body}: {x:?}"); + assert!((x.size(2) - made[0].1.size(2)).abs() < 1e-6, "{body} is as tall as A: {made:?}"); + } + assert_eq!(inputs(&s, 0)[1], "(16 mm) / (2)", "A's pitch"); + assert_eq!(inputs(&s, 1)[2], "(16 mm) / (8 mm)", "B's revolutions"); + let sizes = |s: &Session| [extent(s, "A"), extent(s, "B")]; + let before = sizes(&s); + for u in ["in", "cm", "mm"] { + run(&mut s, "document.units", json!({ "units": u })); + let now = sizes(&s); + assert!(now.iter().zip(&before).all(|(a, b)| rel(*a, *b) < 1e-9), "{u}: {before:?} became {now:?}"); + } + // In an inch design the same bare numbers are inches. + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + run(&mut s, "solid.coil", json!({"diameter": 2, "revolutions": 2, "height": 0.8, "section_size": 0.2, "body_name": "A"})); + let a = shape(&mut s, "A"); + assert!(rel(a.volume, PI * 2.54f64.powi(2) * TAU * 25.4 * 2.0) < 1e-3, "{a:?}"); + let before = extent(&s, "A"); + run(&mut s, "document.units", json!({"units": "mm"})); + assert!(rel(extent(&s, "A"), before) < 1e-9, "{before} became {}", extent(&s, "A")); +} + +#[test] +fn a_box_placed_by_its_centre_reads_its_sizes_in_the_design_units() { + // An inch design: bare sizes are inches, the centre's bare numbers millimetres. + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1, "center": [0, 0, 0], "body_name": "A"})); + run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1, "center": ["10 mm", 100, 0], "body_name": "B"})); + let a = shape(&mut s, "A"); + assert!((a.bbox[0] + 25.4).abs() < 1e-6 && (a.bbox[3] - 25.4).abs() < 1e-6 && (a.bbox[1] + 12.7).abs() < 1e-6, "{a:?}"); + let b = shape(&mut s, "B"); + assert!((b.bbox[0] + 15.4).abs() < 1e-6 && (b.bbox[3] - 35.4).abs() < 1e-6 && (b.bbox[1] - 87.3).abs() < 1e-6, "{b:?}"); + keeps_shape_through(&mut s, &["mm", "in"]); + // A millimetre design: a centre in inches and a bare size in millimetres. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "center": ["0.5 in", 0, 0], "body_name": "C"})); + let c = shape(&mut s, "C"); + assert!((c.bbox[0] - 12.2).abs() < 1e-6 && (c.bbox[3] - 13.2).abs() < 1e-6, "{c:?}"); + keeps_shape_through(&mut s, &["in", "mm"]); +} + +#[test] +fn press_pull_keeps_its_distance_when_the_units_change() { + // A face pushed in: the cut's depth is the distance's size. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"})); + run(&mut s, "solid.press_pull", json!({"face": [20, 15, 20], "distance": -5})); + assert!(rel(shape(&mut s, "Block").volume, 40.0 * 30.0 * 15.0) < 1e-9); + keeps_shape_through(&mut s, &["in", "mm"]); + // A fillet's face: its radius grows by the distance. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"})); + run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 3, "name": "Round"})); + let corner = |r: f64| (4.0 - PI) * r * r / 4.0 * 20.0; + let on = |r: f64| { + let d = r - r / 2f64.sqrt(); + json!([40.0 - d, d, 10.0]) + }; + run(&mut s, "solid.press_pull", json!({"face": on(3.0), "distance": 2})); + assert!(rel(shape(&mut s, "Block").volume, 24000.0 - corner(5.0)) < 1e-4); + keeps_shape_through(&mut s, &["in", "cm", "mm"]); + // Pulled again in an inch design: 0.1 more is 2.54 mm more. + run(&mut s, "document.units", json!({"units": "in"})); + run(&mut s, "solid.press_pull", json!({"face": on(5.0), "distance": 0.1})); + assert!(rel(shape(&mut s, "Block").volume, 24000.0 - corner(7.54)) < 1e-4, "{:?}", inputs(&s, 1)); + keeps_shape_through(&mut s, &["mm", "in"]); +} + +/// A diameter that refers to a unit-less parameter is left as typed: the parameter is read in +/// the design's units, and when they change it keeps the old one written in, so the part keeps +/// its size as it did before. +#[test] +fn a_diameter_from_a_unitless_parameter_is_left_as_typed() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "w", "expression": "20", "unit": ""})); + run(&mut s, "solid.sphere", json!({"diameter": "w", "body_name": "Ball"})); + assert_eq!(inputs(&s, 0), ["(w) / 2"]); + assert!(rel(shape(&mut s, "Ball").volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3); + keeps_shape_through(&mut s, &["in", "mm"]); +} + +/// Beside a unit-less expression that is not a bare number, a bare number stays bare, as it +/// did: the whole expression is read in the design's units. +#[test] +fn bare_numbers_beside_a_unitless_expression_stay_bare() { + let mut s = Session::default(); + run(&mut s, "solid.coil", json!({"diameter": 40, "height": 16, "pitch": "2 * 4", "section_size": 4, "body_name": "A"})); + assert_eq!(inputs(&s, 0)[2], "(16) / (2 * 4)", "the revolutions"); + assert!(s.model.result(s.doc.features[0].id).is_some_and(|r| r.error.is_none())); + run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": ["2 * 5", 0, 0], "body_name": "B"})); + assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].clone()), Some("(2 * 5) - (20) / 2".to_string())); + let b = shape(&mut s, "B"); + assert!(b.bbox[0].abs() < 1e-6 && (b.bbox[3] - 20.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 16a217fb..eb7e297e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -803,6 +803,8 @@ mod construct_tests; #[cfg(test)] mod delete_face_tests; #[cfg(test)] +mod derived_units_tests; +#[cfg(test)] mod display_mesh_tests; #[cfg(test)] mod extent_tests; diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index 28e0cb6d..65a4043e 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -25,7 +25,11 @@ in use can't be deleted (the error lists its users). The design's units (Document Settings in the browser: mm, cm, m, in or ft) set how a bare number typed into a feature is read. In an inch design `2` means 2 in. The Box, Cylinder, Sphere, Torus and Coil dialogs start with their sizes in the design's units, unit shown (`20 mm`, `20 in`). -Changing the units never moves existing geometry. +Changing the units never moves existing geometry. A size worked out from a bare number keeps +the unit the number was given in: a sphere with a bare diameter of 20 has the radius +`(20 mm) / 2` in a millimetre design. Beside a unit-less expression such as `2 * 5` or a +unit-less parameter, which is read in the design's units, the number stays bare and is read +with it. Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`). **Configurations** (`config.*`) keep a table of rows, each with its own parameter values and