From a1df2f2c8cf98ca74f8a85d06aba0c0a6ad75968 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:31 +0200 Subject: [PATCH] SCALE: Reference option (#17) (#27) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. --- crates/engine/src/cmd/modify.rs | 75 ++++++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 39 +++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 04c42ac4..cfc43207 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1297,6 +1297,10 @@ struct SelectThen { objs: Vec, pts: Vec, reference: bool, + /// SCALE Reference: first point of a reference length given by two points. + ref_from: Option, + /// SCALE Reference: the reference length, once known. + ref_len: Option, copy_mode: bool, window: Option, ask_erase: bool, @@ -1310,6 +1314,8 @@ impl SelectThen { objs: Vec::new(), pts: Vec::new(), reference: false, + ref_from: None, + ref_len: None, copy_mode: false, window: None, ask_erase: false, @@ -1357,6 +1363,54 @@ impl SelectThen { _ => Ok(Step::Continue), } } + + /// SCALE Reference: reference length (typed, or two points), then new length (typed, or a + /// point measured from the base point). The scale factor is new length / reference length. + fn scale_reference(&mut self, s: &mut Session, i: Input) -> Result { + let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; + match (self.ref_len, self.ref_from, i) { + (None, None, Input::Point(p)) => { + self.ref_from = Some(p); + Ok(Step::Continue) + } + (None, None, Input::Text(t)) => { + self.ref_len = Some(require_length(number(&t))?); + Ok(Step::Continue) + } + (None, None, Input::Enter) => { + self.ref_len = Some(1.0); + Ok(Step::Continue) + } + (None, Some(a), Input::Point(p)) => { + self.ref_len = Some(require_length(Some(a.dist(p)))?); + Ok(Step::Continue) + } + (Some(r), _, Input::Point(p)) => self.scale_by(s, base, base.dist(p) / r), + (Some(r), _, Input::Text(t)) => { + let n = require_length(number(&t))?; + self.scale_by(s, base, n / r) + } + (_, _, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + + fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { + let f = require_length(Some(f))?; + transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +/// A finite length greater than zero (rejects 0, negatives, NaN and infinity). +fn positive_length(v: Option) -> Option { + v.filter(|f| f.is_finite() && *f > 1e-12) +} + +/// [`positive_length`], or the error that makes the prompt ask again. +fn require_length(v: Option) -> Result { + positive_length(v).ok_or_else(|| EngineError::Other("Requires a positive number.".into())) } impl Interactive for SelectThen { @@ -1406,6 +1460,13 @@ impl Interactive for SelectThen { let pts_from = if self.op == Op::Stretch { 2 } else { 0 }; let k = n.saturating_sub(pts_from); let bp = if self.op == Op::Stretch { self.pts.get(2).copied() } else { base }; + if self.op == Op::Scale && self.reference { + return match (self.ref_len, self.ref_from) { + (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), + (None, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), + (None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), + }; + } match (self.op, k) { (Op::Move | Op::Copy | Op::Stretch, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), @@ -1458,12 +1519,19 @@ impl Interactive for SelectThen { } let off = if self.op == Op::Stretch { 2 } else { 0 }; let k = self.pts.len() - off; + if self.op == Op::Scale && self.reference { + return self.scale_reference(s, i); + } match (self.op, k, i) { (_, _, Input::Keyword(kw)) if kw == "Copy" => { self.copy_mode = true; s.echo("Rotating/scaling a copy of the selected objects."); Ok(Step::Continue) } + (Op::Scale, 1, Input::Keyword(kw)) if kw == "Reference" => { + self.reference = true; + Ok(Step::Continue) + } (_, _, Input::Keyword(kw)) if kw == "Reference" => { self.reference = true; s.echo("Reference: not available yet; enter a value."); @@ -1568,7 +1636,12 @@ impl Interactive for SelectThen { let m = match self.op { Op::Move | Op::Copy | Op::Stretch => Mat3::translate(c - base), Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), - Op::Scale => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { + Some(f) => Mat3::scale_about(base, f), + None if self.ref_len.is_some() => return Vec::new(), + None => return self.ref_from.map(|a| vec![line(a, c)]).unwrap_or_default(), + }, Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), _ => return Vec::new(), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 446639b8..3688c757 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -342,6 +342,45 @@ fn copy_rotate_scale_mirror() { assert_eq!(s.doc().unwrap().model.len(), 5); } +#[test] +fn scale_reference_typed_and_picked() { + // Issue #17: SCALE ▸ Reference scales by new length / reference length. + fn end(s: &Session) -> Vec2 { + let e = s.doc().unwrap().model.iter().next().unwrap(); + let EntityKind::Line(l) = &e.kind else { panic!("expected a line") }; + l.b.xy() + } + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [56, 0]]})).unwrap(); + // Typed lengths: 56 → 100. + for t in ["scale", "all", "", "0,0", "r"] { + s.cmdline(t).unwrap(); + } + assert_eq!(s.current_prompt().unwrap().message, "Specify reference length"); + s.cmdline("56").unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Specify new length"); + s.cmdline("100").unwrap(); + assert!(s.running.is_none()); + assert!(end(&s).near(Vec2::new(100.0, 0.0), 1e-9)); + // Reference from two points, a zero new length is rejected, new length picked from the base point. + for t in ["scale", "all", "", "0,0", "r", "0,0", "100,0"] { + s.cmdline(t).unwrap(); + } + s.cmdline("0").unwrap(); + assert!(s.running.is_some()); + s.cmdline("0,25").unwrap(); + assert!(s.running.is_none()); + assert!(end(&s).near(Vec2::new(25.0, 0.0), 1e-9)); + // A zero reference length is rejected and asked again. + for t in ["scale", "all", "", "0,0", "r", "0"] { + s.cmdline(t).unwrap(); + } + assert_eq!(s.current_prompt().unwrap().message, "Specify reference length"); + s.cmdline("25").unwrap(); + s.cmdline("50").unwrap(); + assert!(end(&s).near(Vec2::new(50.0, 0.0), 1e-9)); +} + #[test] fn layers_and_properties() { let mut s = Session::new();