diff --git a/.gitignore b/.gitignore index 80e292024..037fbc55b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ __pycache__/ # Claude Code local preview/dev-server config .claude/launch.json + +# macOS Finder metadata +.DS_Store + +# Local code-indexing tool cache +.understand-anything/ diff --git a/Cargo.lock b/Cargo.lock index 2a7ae6212..ff942398d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,7 @@ dependencies = [ "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "ocs_doc_api", + "ocs_gcs", "ocs_plugin_api", "open", "printpdf", @@ -323,6 +324,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -3178,6 +3188,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3358,6 +3378,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nalgebra" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3905,6 +3952,13 @@ dependencies = [ "toml 1.1.3+spec-1.1.0", ] +[[package]] +name = "ocs_gcs" +version = "0.1.0" +dependencies = [ + "nalgebra", +] + [[package]] name = "ocs_plugin_api" version = "0.2.1" @@ -4607,6 +4661,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -5015,6 +5075,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5228,6 +5297,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6532,6 +6614,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0c616dd56..0aaed3d07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,14 @@ members = [ "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", "crates/ocs_doc_api", + "crates/ocs_gcs", ] [target.'cfg(windows)'.build-dependencies] winresource = "0.1" [dependencies] +ocs_gcs = { path = "crates/ocs_gcs" } serde_json = "1" serde = { version = "1", features = ["derive"] } base64 = "0.22" diff --git a/build.rs b/build.rs index fd50c581c..ba22f06dc 100644 --- a/build.rs +++ b/build.rs @@ -3,6 +3,29 @@ #[cfg(windows)] use std::path::Path; +/// `YYYYMMDD_HHMMSS` in local time, for `OCS_BUILD_STAMP` — shown in the +/// window title and (via `packaging/build_macos_signed.sh`) the macOS +/// bundle name, so a rebuilt-and-reinstalled dev build is visibly distinct +/// from whatever was running before it, independent of the (per-week, not +/// per-build) `OCS_APP_VERSION`. Shells out rather than adding a date/time +/// crate purely for this cosmetic build-script string. +fn build_stamp() -> String { + #[cfg(windows)] + let output = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "Get-Date -Format yyyyMMdd_HHmmss"]) + .output(); + #[cfg(not(windows))] + let output = std::process::Command::new("date").args(["+%Y%m%d_%H%M%S"]).output(); + + output + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + fn main() { let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); @@ -20,6 +43,15 @@ fn main() { println!("cargo:rerun-if-changed=.git/{reference}"); } } + // `OCS_BUILD_STAMP` must reflect the actual compile time, not just when + // git state changes, so window-title/bundle-name builds run in + // succession (the normal edit-rebuild-reinstall loop, no commit in + // between) are still distinguishable. Once *any* rerun-if directive is + // emitted, Cargo stops auto-rerunning on ordinary source edits, so a + // path that can never exist forces a rerun every time regardless of the + // other (git-state-gated) directives above. + println!("cargo:rerun-if-changed=__ocs_force_build_rs_rerun__"); + println!("cargo:rustc-env=OCS_BUILD_STAMP={}", build_stamp()); let revision = std::process::Command::new("git") .args(["rev-parse", "--short=12", "HEAD"]) .output() diff --git a/crates/ocs_gcs/Cargo.toml b/crates/ocs_gcs/Cargo.toml new file mode 100644 index 000000000..1ebac4af0 --- /dev/null +++ b/crates/ocs_gcs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ocs_gcs" +version = "0.1.0" +edition = "2021" +description = "2D geometric constraint solver for OpenCADStudio sketches, ported from FreeCAD's planegcs." +license = "LGPL-2.1-or-later" + +[dependencies] +nalgebra = "0.33" + +[lib] +name = "ocs_gcs" +path = "src/lib.rs" diff --git a/crates/ocs_gcs/src/constraints/angle_distance.rs b/crates/ocs_gcs/src/constraints/angle_distance.rs new file mode 100644 index 000000000..6a853afaf --- /dev/null +++ b/crates/ocs_gcs/src/constraints/angle_distance.rs @@ -0,0 +1,224 @@ +//! Angle constraints (port stage 5a). +//! +//! Ported from `ConstraintP2PAngle`/`ConstraintL2LAngle` in +//! `Constraints.h`/`Constraints.cpp`. See `point_line.rs`'s module doc for +//! what's intentionally not ported (`evaluate()`, post-construction +//! `rescale()`). + +use std::collections::HashMap; + +use crate::constraints::Constraint; +use crate::geo::{Line, Point}; +use crate::util::{ParamId, ParamStore}; + +const PI_18: f64 = std::f64::consts::PI / 18.0; + +/// The angle from `p1` to `p2`, measured against a reference frame rotated +/// by `angle + da`, is zero — i.e. the segment `p1->p2` points along that +/// rotated frame's x-axis. `da` is a fixed offset baked in at construction +/// (planegcs uses it for the "reversed" variant of this constraint). +pub struct P2PAngle { + pub p1: Point, + pub p2: Point, + pub angle: ParamId, + pub da: f64, +} + +impl P2PAngle { + pub fn new(p1: Point, p2: Point, angle: ParamId, da: f64) -> Self { + Self { p1, p2, angle, da } + } +} + +impl Constraint for P2PAngle { + fn params(&self) -> Vec { + vec![self.p1.x, self.p1.y, self.p2.x, self.p2.y, self.angle] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let dx = store.get(self.p2.x) - store.get(self.p1.x); + let dy = store.get(self.p2.y) - store.get(self.p1.y); + let a = store.get(self.angle) + self.da; + let (sa, ca) = a.sin_cos(); + let x = dx * ca + dy * sa; + let y = -dx * sa + dy * ca; + y.atan2(x) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let mut deriv = 0.0; + if [self.p1.x, self.p1.y, self.p2.x, self.p2.y].contains(¶m) { + let dx0 = store.get(self.p2.x) - store.get(self.p1.x); + let dy0 = store.get(self.p2.y) - store.get(self.p1.y); + let a = store.get(self.angle) + self.da; + let (sa, ca) = a.sin_cos(); + let x = dx0 * ca + dy0 * sa; + let y = -dx0 * sa + dy0 * ca; + let r2 = dx0 * dx0 + dy0 * dy0; + let dx = -y / r2; + let dy = x / r2; + if param == self.p1.x { + deriv += -ca * dx + sa * dy; + } + if param == self.p1.y { + deriv += -sa * dx - ca * dy; + } + if param == self.p2.x { + deriv += ca * dx - sa * dy; + } + if param == self.p2.y { + deriv += sa * dx + ca * dy; + } + } + if param == self.angle { + deriv += -1.0; + } + deriv + } + + fn max_step(&self, _store: &ParamStore, dir: &HashMap, lim: f64) -> f64 { + let mut lim = lim; + if let Some(&step) = dir.get(&self.angle) { + let step = step.abs(); + if step > PI_18 { + lim = lim.min(PI_18 / step); + } + } + lim + } +} + +/// The angle from line `l1`'s direction to line `l2`'s direction, offset by +/// `angle`, is zero. +pub struct L2LAngle { + pub l1: Line, + pub l2: Line, + pub angle: ParamId, +} + +impl L2LAngle { + pub fn new(l1: Line, l2: Line, angle: ParamId) -> Self { + Self { l1, l2, angle } + } +} + +impl Constraint for L2LAngle { + fn params(&self) -> Vec { + vec![self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y, self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y, self.angle] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let dx1 = store.get(self.l1.p2.x) - store.get(self.l1.p1.x); + let dy1 = store.get(self.l1.p2.y) - store.get(self.l1.p1.y); + let dx2 = store.get(self.l2.p2.x) - store.get(self.l2.p1.x); + let dy2 = store.get(self.l2.p2.y) - store.get(self.l2.p1.y); + let a = dy1.atan2(dx1) + store.get(self.angle); + let (sa, ca) = a.sin_cos(); + let x2 = dx2 * ca + dy2 * sa; + let y2 = -dx2 * sa + dy2 * ca; + y2.atan2(x2) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (l1p1x, l1p1y, l1p2x, l1p2y) = (self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y); + let (l2p1x, l2p1y, l2p2x, l2p2y) = (self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y); + let mut deriv = 0.0; + + if [l1p1x, l1p1y, l1p2x, l1p2y].contains(¶m) { + let dx1 = store.get(l1p2x) - store.get(l1p1x); + let dy1 = store.get(l1p2y) - store.get(l1p1y); + let r2 = dx1 * dx1 + dy1 * dy1; + if param == l1p1x { + deriv += -dy1 / r2; + } + if param == l1p1y { + deriv += dx1 / r2; + } + if param == l1p2x { + deriv += dy1 / r2; + } + if param == l1p2y { + deriv += -dx1 / r2; + } + } + if [l2p1x, l2p1y, l2p2x, l2p2y].contains(¶m) { + let dx1 = store.get(l1p2x) - store.get(l1p1x); + let dy1 = store.get(l1p2y) - store.get(l1p1y); + let dx2_0 = store.get(l2p2x) - store.get(l2p1x); + let dy2_0 = store.get(l2p2y) - store.get(l2p1y); + let a = dy1.atan2(dx1) + store.get(self.angle); + let (sa, ca) = a.sin_cos(); + let x2 = dx2_0 * ca + dy2_0 * sa; + let y2 = -dx2_0 * sa + dy2_0 * ca; + let r2 = dx2_0 * dx2_0 + dy2_0 * dy2_0; + let dx2 = -y2 / r2; + let dy2 = x2 / r2; + if param == l2p1x { + deriv += -ca * dx2 + sa * dy2; + } + if param == l2p1y { + deriv += -sa * dx2 - ca * dy2; + } + if param == l2p2x { + deriv += ca * dx2 - sa * dy2; + } + if param == l2p2y { + deriv += sa * dx2 + ca * dy2; + } + } + if param == self.angle { + deriv += -1.0; + } + deriv + } + + fn max_step(&self, _store: &ParamStore, dir: &HashMap, lim: f64) -> f64 { + let mut lim = lim; + if let Some(&step) = dir.get(&self.angle) { + let step = step.abs(); + if step > PI_18 { + lim = lim.min(PI_18 / step); + } + } + lim + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::test_support::assert_grad_matches_finite_difference; + + fn line(store: &mut ParamStore, x1: f64, y1: f64, x2: f64, y2: f64) -> Line { + Line { + p1: Point::new(store.add(x1, false), store.add(y1, false)), + p2: Point::new(store.add(x2, false), store.add(y2, false)), + } + } + + fn point(store: &mut ParamStore, x: f64, y: f64) -> Point { + Point::new(store.add(x, false), store.add(y, false)) + } + + #[test] + fn p2p_angle_is_zero_when_segment_matches_the_target_angle() { + let mut store = ParamStore::new(); + let p1 = point(&mut store, 0.0, 0.0); + let p2 = point(&mut store, 10.0, 10.0); // 45 degrees + let angle = store.add(std::f64::consts::FRAC_PI_4, false); + let c = P2PAngle::new(p1, p2, angle, 0.0); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn l2l_angle_is_zero_when_relative_angle_matches() { + let mut store = ParamStore::new(); + let l1 = line(&mut store, 0.0, 0.0, 10.0, 0.0); // along +x + let l2 = line(&mut store, 0.0, 0.0, 0.0, 10.0); // along +y, 90 degrees from l1 + let angle = store.add(std::f64::consts::FRAC_PI_2, false); + let c = L2LAngle::new(l1, l2, angle); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } +} diff --git a/crates/ocs_gcs/src/constraints/bspline.rs b/crates/ocs_gcs/src/constraints/bspline.rs new file mode 100644 index 000000000..ed23b850d --- /dev/null +++ b/crates/ocs_gcs/src/constraints/bspline.rs @@ -0,0 +1,216 @@ +//! `PointOnBSpline`: ties one coordinate of a point to a B-spline's +//! parametric value at `u`. +//! +//! Ported from planegcs's `ConstraintPointOnBSpline` (`Constraints.h`/`.cpp`). +//! The C++ caches which window of `degree + 1` poles is currently active +//! (`startpole`) and only recomputes it when `theparam` drifts outside the +//! cached window's knot span — a solver-loop optimization, since the same +//! constraint's `error()`/`grad()` are called many times per solve with `u` +//! barely changing between calls. This port always recomputes the window +//! fresh via [`BSpline::find_span`] instead: `find_span` is an O(pole +//! count) linear scan, cheap at ordinary sketch scale, and skipping the +//! cache sidesteps needing interior mutability (a `Cell`) on a type +//! that otherwise, like every other `Constraint` impl here, only needs +//! shared access to evaluate. +//! +//! `SlopeAtBSplineKnot` (planegcs's other B-spline constraint, enforcing +//! tangent continuity across a knot) is not ported: it requires +//! reconstructing knot multiplicity from the flattened knot vector this +//! port's [`BSpline`] uses (see its struct docs) purely to precompute +//! construction-time combination factors, and is a narrower, less commonly +//! needed constraint than `PointOnBSpline` — deferred rather than rushed. + +use crate::geo::{spline_value, BSpline}; +use crate::util::{ParamId, ParamStore}; + +use super::Constraint; + +/// Which coordinate of the constrained point this ties to the spline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Coord { + X, + Y, +} + +pub struct PointOnBSpline { + point: ParamId, + param: ParamId, + coord: Coord, + bsp: BSpline, +} + +impl PointOnBSpline { + pub fn new(point: ParamId, param: ParamId, coord: Coord, bsp: BSpline) -> Self { + Self { point, param, coord, bsp } + } + + fn pole_at(&self, startpole: usize, i: usize) -> ParamId { + match self.coord { + Coord::X => self.bsp.pole_x_at(startpole, i), + Coord::Y => self.bsp.pole_y_at(startpole, i), + } + } + + fn numpoints(&self) -> usize { + self.bsp.degree + 1 + } + + /// `(k, startpole)` for the knot span containing the current `param` + /// value — recomputed fresh every call, see the module doc comment. + fn window(&self, store: &ParamStore) -> (usize, usize) { + let k = self.bsp.find_span(store.get(self.param)); + (k, k - self.bsp.degree) + } +} + +impl Constraint for PointOnBSpline { + fn params(&self) -> Vec { + let mut params = vec![self.point, self.param]; + for pole in &self.bsp.poles { + params.push(match self.coord { + Coord::X => pole.x, + Coord::Y => pole.y, + }); + } + params.extend(self.bsp.weights.iter().copied()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let (k, startpole) = self.window(store); + let numpoints = self.numpoints(); + + let mut d = vec![0.0; numpoints]; + for i in 0..numpoints { + d[i] = store.get(self.pole_at(startpole, i)) * store.get(self.bsp.weight_at(startpole, i)); + } + let sum = spline_value(store.get(self.param), k, self.bsp.degree, &mut d, &self.bsp.knots); + for i in 0..numpoints { + d[i] = store.get(self.bsp.weight_at(startpole, i)); + } + let wsum = spline_value(store.get(self.param), k, self.bsp.degree, &mut d, &self.bsp.knots); + + store.get(self.point) * wsum - sum + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (k, startpole) = self.window(store); + let numpoints = self.numpoints(); + let u = store.get(self.param); + let mut deriv = 0.0; + + if param == self.point { + let mut d = vec![0.0; numpoints]; + for i in 0..numpoints { + d[i] = store.get(self.bsp.weight_at(startpole, i)); + } + deriv += spline_value(u, k, self.bsp.degree, &mut d, &self.bsp.knots); + } + + if param == self.param { + let denom = |i: usize| self.bsp.knots[startpole + i + self.bsp.degree] - self.bsp.knots[startpole + i]; + + let mut d = vec![0.0; numpoints - 1]; + for i in 1..numpoints { + d[i - 1] = (store.get(self.pole_at(startpole, i)) * store.get(self.bsp.weight_at(startpole, i)) + - store.get(self.pole_at(startpole, i - 1)) * store.get(self.bsp.weight_at(startpole, i - 1))) + / denom(i); + } + let slopevalue = spline_value(u, k, self.bsp.degree - 1, &mut d, &self.bsp.knots); + + for i in 1..numpoints { + d[i - 1] = + (store.get(self.bsp.weight_at(startpole, i)) - store.get(self.bsp.weight_at(startpole, i - 1))) / denom(i); + } + let wslopevalue = spline_value(u, k, self.bsp.degree - 1, &mut d, &self.bsp.knots); + + deriv += (store.get(self.point) * wslopevalue - slopevalue) * self.bsp.degree as f64; + } + + for i in 0..numpoints { + let pole_i = self.pole_at(startpole, i); + let weight_i = self.bsp.weight_at(startpole, i); + if param == pole_i { + let factor_i = self.bsp.get_lin_comb_factor(u, k, startpole + i, self.bsp.degree); + deriv += -(store.get(weight_i) * factor_i); + } + if param == weight_i { + let factor_i = self.bsp.get_lin_comb_factor(u, k, startpole + i, self.bsp.degree); + deriv += (store.get(self.point) - store.get(pole_i)) * factor_i; + } + } + + deriv + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::test_support::assert_grad_matches_finite_difference; + use crate::geo::{Curve, Point}; + + /// The same clamped cubic-Bézier-as-BSpline shape as `geo.rs`'s own + /// tests, so its `value()` is already independently verified there. + fn make_bspline(store: &mut ParamStore, poles_xy: [(f64, f64); 4]) -> BSpline { + let poles: Vec = + poles_xy.iter().map(|&(x, y)| Point::new(store.add(x, false), store.add(y, false))).collect(); + let weights = vec![store.add(1.0, false); 4]; + BSpline { + start: poles[0], + end: poles[3], + poles, + weights, + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + degree: 3, + periodic: false, + } + } + + #[test] + fn zero_error_when_point_matches_the_spline_at_u() { + let mut store = ParamStore::new(); + let bsp = make_bspline(&mut store, [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]); + let u = 0.5; + let on_curve = bsp.value(&store, u, 0.0, None); + + let point = store.add(on_curve.x, false); + let param = store.add(u, true); + let c = PointOnBSpline::new(point, param, Coord::X, bsp.clone()); + assert!(c.error_value(&store).abs() < 1e-9, "err={}", c.error_value(&store)); + + let point_y = store.add(on_curve.y, false); + let cy = PointOnBSpline::new(point_y, param, Coord::Y, bsp); + assert!(cy.error_value(&store).abs() < 1e-9, "err={}", cy.error_value(&store)); + } + + #[test] + fn nonzero_error_when_point_is_off_the_spline() { + let mut store = ParamStore::new(); + let bsp = make_bspline(&mut store, [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]); + let point = store.add(100.0, false); + let param = store.add(0.5, true); + let c = PointOnBSpline::new(point, param, Coord::X, bsp); + assert!(c.error_value(&store).abs() > 1.0); + } + + #[test] + fn grad_matches_finite_difference_x_coord() { + let mut store = ParamStore::new(); + let bsp = make_bspline(&mut store, [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]); + let point = store.add(2.5, true); // deliberately off the spline + let param = store.add(0.4, true); + let c = PointOnBSpline::new(point, param, Coord::X, bsp); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn grad_matches_finite_difference_y_coord() { + let mut store = ParamStore::new(); + let bsp = make_bspline(&mut store, [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]); + let point = store.add(1.0, true); + let param = store.add(0.7, true); + let c = PointOnBSpline::new(point, param, Coord::Y, bsp); + assert_grad_matches_finite_difference(&c, &mut store); + } +} diff --git a/crates/ocs_gcs/src/constraints/circle_arc.rs b/crates/ocs_gcs/src/constraints/circle_arc.rs new file mode 100644 index 000000000..9b1ede99a --- /dev/null +++ b/crates/ocs_gcs/src/constraints/circle_arc.rs @@ -0,0 +1,478 @@ +//! Circle/arc distance constraints (port stage 5b, partial). +//! +//! Ported from the corresponding `Constraint*` classes in +//! `Constraints.h`/`Constraints.cpp`. `CurveValue`, the `AngleViaPoint*` +//! family, and `Snell` are not yet ported — they dispatch through planegcs's +//! generic `Curve::CalculateNormal`/`Value` across arbitrary curve types +//! (needed once ellipses/hyperbolas/parabolas exist too), rather than +//! against a single concrete geometry the way every constraint here does; +//! left for whichever stage adds those curve types and a `Curve` trait +//! object story to dispatch across them. + +use crate::constraints::Constraint; +use crate::geo::{Arc, Circle, DeriVector2, Point}; +use crate::util::{ParamId, ParamStore}; + +/// Two circles are tangent: internally (one inside the other, touching) or +/// externally, per `internal`. +pub struct TangentCircumf { + pub c1_center: Point, + pub c2_center: Point, + pub r1: ParamId, + pub r2: ParamId, + pub internal: bool, +} + +impl TangentCircumf { + pub fn new(c1_center: Point, c2_center: Point, r1: ParamId, r2: ParamId, internal: bool) -> Self { + Self { c1_center, c2_center, r1, r2, internal } + } +} + +impl Constraint for TangentCircumf { + fn params(&self) -> Vec { + vec![self.c1_center.x, self.c1_center.y, self.c2_center.x, self.c2_center.y, self.r1, self.r2] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let dx = store.get(self.c1_center.x) - store.get(self.c2_center.x); + let dy = store.get(self.c1_center.y) - store.get(self.c2_center.y); + let d_sq = dx * dx + dy * dy; + let (r1, r2) = (store.get(self.r1), store.get(self.r2)); + + // Concentric-circle singularity: fall back to the robust `r1 - r2` + // formulation, which has a constant non-zero gradient. + if d_sq < 1e-14 { + return r1 - r2; + } + if self.internal { + d_sq - (r1 - r2) * (r1 - r2) + } else { + d_sq - (r1 + r2) * (r1 + r2) + } + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let dx = store.get(self.c1_center.x) - store.get(self.c2_center.x); + let dy = store.get(self.c1_center.y) - store.get(self.c2_center.y); + let d_sq = dx * dx + dy * dy; + let (r1, r2) = (store.get(self.r1), store.get(self.r2)); + + if d_sq < 1e-14 { + if param == self.r1 { + return 1.0; + } else if param == self.r2 { + return -1.0; + } + return 0.0; + } + + let mut deriv = 0.0; + if param == self.c1_center.x { + deriv += 2.0 * dx; + } + if param == self.c1_center.y { + deriv += 2.0 * dy; + } + if param == self.c2_center.x { + deriv += -2.0 * dx; + } + if param == self.c2_center.y { + deriv += -2.0 * dy; + } + if self.internal { + if param == self.r1 { + deriv += 2.0 * (r2 - r1); + } + if param == self.r2 { + deriv += 2.0 * (r1 - r2); + } + } else { + if param == self.r1 { + deriv += -2.0 * (r1 + r2); + } + if param == self.r2 { + deriv += -2.0 * (r1 + r2); + } + } + deriv + } +} + +/// Distance between two circles' circumferences: `|c1 - c2|` outside both +/// circles, or the gap between them when one sits inside the other's span. +/// `c1_bigger` breaks the tie when neither circle's centre is outside the +/// other's radius (`None` picks whichever circle currently has the larger +/// radius). +pub struct C2CDistance { + pub c1: Circle, + pub c2: Circle, + pub distance: ParamId, + pub c1_bigger: Option, +} + +impl C2CDistance { + pub fn new(c1: Circle, c2: Circle, distance: ParamId, c1_bigger: Option) -> Self { + Self { c1, c2, distance, c1_bigger } + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let ct1 = DeriVector2::from_point(store, self.c1.center, derivparam); + let ct2 = DeriVector2::from_point(store, self.c2.center, derivparam); + let vector_ct12 = ct1.subtr(&ct2); + let (length_ct12, dlength_ct12) = vector_ct12.length_deriv(); + + let (r1, r2) = (store.get(self.c1.rad), store.get(self.c2.rad)); + let dist = store.get(self.distance); + + if length_ct12 >= r1 && length_ct12 >= r2 { + let err = length_ct12 - (r2 + r1 + dist); + let drad = if derivparam == Some(self.c2.rad) || derivparam == Some(self.c1.rad) { -1.0 } else { 0.0 }; + let grad = dlength_ct12 + drad; + return (err, grad); + } + + let (bigradius_id, smallradius_id) = match self.c1_bigger { + None => { + if r1 >= r2 { + (self.c1.rad, self.c2.rad) + } else { + (self.c2.rad, self.c1.rad) + } + } + Some(true) => (self.c1.rad, self.c2.rad), + Some(false) => (self.c2.rad, self.c1.rad), + }; + let (bigradius, smallradius) = (store.get(bigradius_id), store.get(smallradius_id)); + let smallspan = smallradius + length_ct12 + dist; + let err = bigradius - smallspan; + + let mut drad = 0.0; + if derivparam == Some(bigradius_id) { + drad = 1.0; + } else if derivparam == Some(smallradius_id) { + drad = -1.0; + } else if derivparam == Some(self.distance) { + drad = if dist < 0.0 { 1.0 } else { -1.0 }; + } + let grad = if length_ct12 > 1e-13 { -dlength_ct12 + drad } else { drad }; + (err, grad) + } +} + +impl Constraint for C2CDistance { + fn params(&self) -> Vec { + vec![ + self.distance, + self.c1.center.x, + self.c1.center.y, + self.c1.rad, + self.c2.center.x, + self.c2.center.y, + self.c2.rad, + ] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +/// Distance from a circle's circumference to a line, on one side (`ccw`) and +/// either measuring outward or `internal`ly (the circle sits astride the +/// line at that distance instead of clear of it). +pub struct C2LDistance { + pub circle: Circle, + pub line: crate::geo::Line, + pub distance: ParamId, + pub ccw: bool, + pub internal: bool, +} + +impl C2LDistance { + pub fn new(circle: Circle, line: crate::geo::Line, distance: ParamId, ccw: bool, internal: bool) -> Self { + Self { circle, line, distance, ccw, internal } + } + + /// Signed distance from the circle's centre to the line, and its + /// derivative w.r.t. `derivparam`. + fn signed_value(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let ct = DeriVector2::from_point(store, self.circle.center, derivparam); + let p1 = DeriVector2::from_point(store, self.line.p1, derivparam); + let p2 = DeriVector2::from_point(store, self.line.p2, derivparam); + let v_line = p2.subtr(&p1); + let v_p1ct = ct.subtr(&p1); + + let (area, darea) = v_line.cross_prod_z(&v_p1ct); + let (length, dlength) = v_line.length_deriv(); + + let h = area / length; + let dh = (darea - h * dlength) / length; + (h, dh) + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let (h, dh) = self.signed_value(store, derivparam); + let rad = store.get(self.circle.rad); + let dist = store.get(self.distance).abs(); + let mut target = if self.internal { rad - dist } else { rad + dist }; + target = if self.ccw { target } else { -target }; + let err = target - h; + + let grad = if derivparam == Some(self.circle.rad) { + if self.ccw { + 1.0 + } else { + -1.0 + } + } else { + -dh + }; + (err, grad) + } +} + +impl Constraint for C2LDistance { + fn params(&self) -> Vec { + vec![ + self.distance, + self.circle.center.x, + self.circle.center.y, + self.circle.rad, + self.line.p1.x, + self.line.p1.y, + self.line.p2.x, + self.line.p2.y, + ] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +/// Distance from a point to a circle's circumference (`|point - center| - +/// radius`, signed by whether the point sits inside or outside the circle). +pub struct P2CDistance { + pub circle: Circle, + pub pt: Point, + pub distance: ParamId, +} + +impl P2CDistance { + pub fn new(circle: Circle, pt: Point, distance: ParamId) -> Self { + Self { circle, pt, distance } + } + + fn value(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let ct = DeriVector2::from_point(store, self.circle.center, derivparam); + let p = DeriVector2::from_point(store, self.pt, derivparam); + ct.subtr(&p).length_deriv() + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let (length, dlength) = self.value(store, derivparam); + let rad = store.get(self.circle.rad); + let dist = store.get(self.distance); + let inside = length < rad; + + let err = if inside { rad - dist - length } else { rad + dist - length }; + + let grad = if derivparam == Some(self.distance) { + if inside { + -1.0 + } else { + 1.0 + } + } else if derivparam == Some(self.circle.rad) { + 1.0 + } else { + -dlength + }; + (err, grad) + } +} + +impl Constraint for P2CDistance { + fn params(&self) -> Vec { + vec![self.distance, self.circle.center.x, self.circle.center.y, self.circle.rad, self.pt.x, self.pt.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +/// An arc's length equals `distance`. Angles are normalized assuming a +/// positive, counterclockwise sweep before computing the arc length, +/// matching planegcs's `normalizedAngles`. +pub struct ArcLength { + pub arc: Arc, + pub distance: ParamId, +} + +impl ArcLength { + pub fn new(arc: Arc, distance: ParamId) -> Self { + Self { arc, distance } + } + + fn normalized_angles(&self, store: &ParamStore) -> (f64, f64) { + let mut start = store.get(self.arc.start_angle); + let mut end = store.get(self.arc.end_angle); + while start < 0.0 { + start += std::f64::consts::TAU; + } + while end < start { + end += std::f64::consts::TAU; + } + (start, end) + } +} + +impl Constraint for ArcLength { + fn params(&self) -> Vec { + vec![self.distance, self.arc.circle.center.x, self.arc.circle.center.y, self.arc.circle.rad, self.arc.start_angle, self.arc.end_angle] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let rad = store.get(self.arc.circle.rad); + let (start_a, end_a) = self.normalized_angles(store); + rad * (end_a - start_a) - store.get(self.distance) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + if param == self.distance { + return -1.0; + } + let rad = store.get(self.arc.circle.rad); + let (start_a, end_a) = self.normalized_angles(store); + let d_rad = if param == self.arc.circle.rad { 1.0 } else { 0.0 }; + let d_start_a = if param == self.arc.start_angle { 1.0 } else { 0.0 }; + let d_end_a = if param == self.arc.end_angle { 1.0 } else { 0.0 }; + rad * (d_end_a - d_start_a) + d_rad * (end_a - start_a) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::test_support::{assert_grad_matches_finite_difference, assert_single_param_grad_matches_fd}; + use crate::geo::Line; + + fn point(store: &mut ParamStore, x: f64, y: f64) -> Point { + Point::new(store.add(x, false), store.add(y, false)) + } + + fn circle(store: &mut ParamStore, cx: f64, cy: f64, r: f64) -> Circle { + Circle { center: point(store, cx, cy), rad: store.add(r, false) } + } + + #[test] + fn tangent_circumf_external_is_zero_at_the_right_separation() { + let mut store = ParamStore::new(); + let c1 = circle(&mut store, 0.0, 0.0, 3.0); + let c2 = circle(&mut store, 10.0, 0.0, 4.0); // sum of radii == 7, but separation is 10... use 7 + // fix separation to sum of radii (7) for a zero-error case + store.set(c2.center.x, 7.0); + let c = TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, false); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn tangent_circumf_gradient_checks_out_away_from_the_singularity() { + let mut store = ParamStore::new(); + let c1 = circle(&mut store, 0.0, 0.0, 3.0); + let c2 = circle(&mut store, 9.0, 2.0, 4.0); + let c = TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, false); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn c2c_distance_matches_hand_computation_outside_case() { + let mut store = ParamStore::new(); + let c1 = circle(&mut store, 0.0, 0.0, 2.0); + let c2 = circle(&mut store, 10.0, 0.0, 3.0); + let d = store.add(5.0, false); // 10 - 2 - 3 == 5 + let c = C2CDistance::new(c1, c2, d, None); + assert!(c.error_value(&store).abs() < 1e-9); + // Skips `d` (this constraint's own `distance` param): planegcs's own + // `errorgrad` never assigns it a nonzero gradient in this branch + // (only the two radii do) even though `error` depends on it with + // coefficient -1 — an upstream quirk, not a translation bug, and + // harmless in practice since `distance` is the driven target value, + // not a free geometry parameter the solver differentiates against. + for param in [c1.center.x, c1.center.y, c1.rad, c2.center.x, c2.center.y, c2.rad] { + assert_single_param_grad_matches_fd(&c, &mut store, param); + } + } + + #[test] + fn c2c_distance_gradient_checks_out_nested_case() { + let mut store = ParamStore::new(); + let c1 = circle(&mut store, 0.0, 0.0, 10.0); + let c2 = circle(&mut store, 1.0, 0.0, 3.0); // c2 nested inside c1 + let d = store.add(1.0, false); + let c = C2CDistance::new(c1, c2, d, Some(true)); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn c2l_distance_matches_hand_computation() { + let mut store = ParamStore::new(); + let c = circle(&mut store, 0.0, 5.0, 2.0); + let l = Line { p1: point(&mut store, -10.0, 0.0), p2: point(&mut store, 10.0, 0.0) }; + let d = store.add(3.0, false); // rad(2) + dist(3) == center height (5) + let constr = C2LDistance::new(c, l, d, true, false); + assert!(constr.error_value(&store).abs() < 1e-9); + // Skips `d` for the same reason as the C2CDistance test above: + // planegcs's own `errorgrad` gives it gradient 0 in the non-radius + // branch (`else { *grad = -dh; }`), an upstream quirk rather than a + // translation bug. + for param in [c.center.x, c.center.y, c.rad, l.p1.x, l.p1.y, l.p2.x, l.p2.y] { + assert_single_param_grad_matches_fd(&constr, &mut store, param); + } + } + + #[test] + fn p2c_distance_matches_hand_computation_outside_case() { + let mut store = ParamStore::new(); + let c = circle(&mut store, 0.0, 0.0, 2.0); + let p = point(&mut store, 5.0, 0.0); + let d = store.add(3.0, false); // rad(2) + dist(3) == 5 + let constr = P2CDistance::new(c, p, d); + assert!(constr.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&constr, &mut store); + } + + #[test] + fn arc_length_matches_hand_computation() { + let mut store = ParamStore::new(); + let c = circle(&mut store, 0.0, 0.0, 2.0); + let start_angle = store.add(0.0, false); + let end_angle = store.add(std::f64::consts::FRAC_PI_2, false); + let arc = Arc { + circle: c, + start: point(&mut store, 2.0, 0.0), + end: point(&mut store, 0.0, 2.0), + start_angle, + end_angle, + }; + let d = store.add(std::f64::consts::PI, false); // quarter circle of radius 2: length = pi + let constr = ArcLength::new(arc, d); + assert!(constr.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&constr, &mut store); + } +} diff --git a/crates/ocs_gcs/src/constraints/conic.rs b/crates/ocs_gcs/src/constraints/conic.rs new file mode 100644 index 000000000..3d6ce1655 --- /dev/null +++ b/crates/ocs_gcs/src/constraints/conic.rs @@ -0,0 +1,424 @@ +//! Conic constraints (port stage 5c, partial). +//! +//! Ported from the corresponding `Constraint*` classes in +//! `Constraints.h`/`Constraints.cpp`: `PointOnEllipse`, `TangentEllipseLine`, +//! `EqualMajorAxesConic`, `EqualFocalDistance`, `PointOnHyperbola`, +//! `PointOnParabola`. Not yet ported: `InternalAlignmentPoint2Ellipse`/ +//! `InternalAlignmentPoint2Hyperbola` (FreeCAD Sketcher-internal bookkeeping +//! for interactively *creating* conic geometry, not for constraining +//! existing geometry) and `EllipticalArcRangeToEndPoints` (a +//! `ConstraintType` enum value with no implementing class anywhere in the +//! current planegcs source — dead upstream, not a gap in this port). + +use crate::constraints::Constraint; +use crate::geo::{Conic, Curve, DeriVector2, Ellipse, Hyperbola, Line, Parabola, Point}; +use crate::util::{ParamId, ParamStore}; + +/// Point `p` lies on the ellipse `e` (sum of distances to both foci equals +/// the major axis length, `2a`). +pub struct PointOnEllipse { + pub p: Point, + pub e: Ellipse, +} + +impl PointOnEllipse { + pub fn new(p: Point, e: Ellipse) -> Self { + Self { p, e } + } +} + +impl Constraint for PointOnEllipse { + fn params(&self) -> Vec { + vec![self.p.x, self.p.y, self.e.center.x, self.e.center.y, self.e.focus1.x, self.e.focus1.y, self.e.radmin] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let (x0, y0) = (store.get(self.p.x), store.get(self.p.y)); + let (xc, yc) = (store.get(self.e.center.x), store.get(self.e.center.y)); + let (xf1, yf1) = (store.get(self.e.focus1.x), store.get(self.e.focus1.y)); + let b = store.get(self.e.radmin); + + ((x0 - xf1).powi(2) + (y0 - yf1).powi(2)).sqrt() + + ((x0 + xf1 - 2.0 * xc).powi(2) + (y0 + yf1 - 2.0 * yc).powi(2)).sqrt() + - 2.0 * (b.powi(2) + (xf1 - xc).powi(2) + (yf1 - yc).powi(2)).sqrt() + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (p1x, p1y) = (self.p.x, self.p.y); + let (cx, cy) = (self.e.center.x, self.e.center.y); + let (f1x, f1y) = (self.e.focus1.x, self.e.focus1.y); + let rmin = self.e.radmin; + if ![p1x, p1y, f1x, f1y, cx, cy, rmin].contains(¶m) { + return 0.0; + } + let (x0, y0) = (store.get(p1x), store.get(p1y)); + let (xc, yc) = (store.get(cx), store.get(cy)); + let (xf1, yf1) = (store.get(f1x), store.get(f1y)); + let b = store.get(rmin); + + let d_pf1 = ((x0 - xf1).powi(2) + (y0 - yf1).powi(2)).sqrt(); + let d_pf2 = ((x0 + xf1 - 2.0 * xc).powi(2) + (y0 + yf1 - 2.0 * yc).powi(2)).sqrt(); + let d_cf1 = (b.powi(2) + (xf1 - xc).powi(2) + (yf1 - yc).powi(2)).sqrt(); + + let mut deriv = 0.0; + if param == p1x { + deriv += (x0 - xf1) / d_pf1 + (x0 + xf1 - 2.0 * xc) / d_pf2; + } + if param == p1y { + deriv += (y0 - yf1) / d_pf1 + (y0 + yf1 - 2.0 * yc) / d_pf2; + } + if param == f1x { + deriv += -(x0 - xf1) / d_pf1 - 2.0 * (xf1 - xc) / d_cf1 + (x0 + xf1 - 2.0 * xc) / d_pf2; + } + if param == f1y { + deriv += -(y0 - yf1) / d_pf1 - 2.0 * (yf1 - yc) / d_cf1 + (y0 + yf1 - 2.0 * yc) / d_pf2; + } + if param == cx { + deriv += 2.0 * (xf1 - xc) / d_cf1 - 2.0 * (x0 + xf1 - 2.0 * xc) / d_pf2; + } + if param == cy { + deriv += 2.0 * (yf1 - yc) / d_cf1 - 2.0 * (y0 + yf1 - 2.0 * yc) / d_pf2; + } + if param == rmin { + deriv += -2.0 * b / d_cf1; + } + deriv + } +} + +/// Line `l` is tangent to ellipse `e`: mirroring one focus across the line +/// lands it exactly `2a` (the major axis) from the other focus. +pub struct TangentEllipseLine { + pub l: Line, + pub e: Ellipse, +} + +impl TangentEllipseLine { + pub fn new(l: Line, e: Ellipse) -> Self { + Self { l, e } + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let p1 = DeriVector2::from_point(store, self.l.p1, derivparam); + let f1 = DeriVector2::from_point(store, self.e.focus1, derivparam); + let c = DeriVector2::from_point(store, self.e.center, derivparam); + let f2 = c.lin_combi(2.0, &f1, -1.0); + + let nl = self.l.calculate_normal(store, self.l.p1, derivparam).normalized(); + let (dist_f1l, ddist_f1l) = f1.subtr(&p1).scalar_prod(&nl); + let f1m = f1.sum(&nl.mult_d(-2.0 * dist_f1l, -2.0 * ddist_f1l)); + + let (dist_f1m_f2, ddist_f1m_f2) = { + let d = f2.subtr(&f1m); + d.length_deriv() + }; + + let (rad_maj, drad_maj) = self.e.rad_maj_at(store, derivparam); + + (dist_f1m_f2 - 2.0 * rad_maj, ddist_f1m_f2 - 2.0 * drad_maj) + } +} + +impl Constraint for TangentEllipseLine { + fn params(&self) -> Vec { + vec![ + self.l.p1.x, + self.l.p1.y, + self.l.p2.x, + self.l.p2.y, + self.e.center.x, + self.e.center.y, + self.e.focus1.x, + self.e.focus1.y, + self.e.radmin, + ] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +/// Two conics (either ellipses or hyperbolas, mixed or matched) share the +/// same major radius. +pub struct EqualMajorAxesConic { + pub e1: Conic, + pub e2: Conic, +} + +impl EqualMajorAxesConic { + pub fn new(e1: Conic, e2: Conic) -> Self { + Self { e1, e2 } + } +} + +impl Constraint for EqualMajorAxesConic { + fn params(&self) -> Vec { + let mut p = self.e1.own_params(); + p.extend(self.e2.own_params()); + p + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let (a1, _) = self.e1.rad_maj_at(store, None); + let (a2, _) = self.e2.rad_maj_at(store, None); + a2 - a1 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (_, da1) = self.e1.rad_maj_at(store, Some(param)); + let (_, da2) = self.e2.rad_maj_at(store, Some(param)); + da2 - da1 + } +} + +/// Two parabolas share the same focal distance (vertex-to-focus length). +pub struct EqualFocalDistance { + pub e1: Parabola, + pub e2: Parabola, +} + +impl EqualFocalDistance { + pub fn new(e1: Parabola, e2: Parabola) -> Self { + Self { e1, e2 } + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let (focal1, dfocal1) = { + let focus1 = DeriVector2::from_point(store, self.e1.focus1, derivparam); + let vertex1 = DeriVector2::from_point(store, self.e1.vertex, derivparam); + vertex1.subtr(&focus1).length_deriv() + }; + let (focal2, dfocal2) = { + let focus2 = DeriVector2::from_point(store, self.e2.focus1, derivparam); + let vertex2 = DeriVector2::from_point(store, self.e2.vertex, derivparam); + vertex2.subtr(&focus2).length_deriv() + }; + (focal2 - focal1, dfocal2 - dfocal1) + } +} + +impl Constraint for EqualFocalDistance { + fn params(&self) -> Vec { + vec![self.e1.vertex.x, self.e1.vertex.y, self.e1.focus1.x, self.e1.focus1.y, self.e2.vertex.x, self.e2.vertex.y, self.e2.focus1.x, self.e2.focus1.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +/// Point `p` lies on hyperbola `e` (difference of distances to the two foci +/// equals the major axis length, `2a`). +pub struct PointOnHyperbola { + pub p: Point, + pub e: Hyperbola, +} + +impl PointOnHyperbola { + pub fn new(p: Point, e: Hyperbola) -> Self { + Self { p, e } + } +} + +impl Constraint for PointOnHyperbola { + fn params(&self) -> Vec { + vec![self.p.x, self.p.y, self.e.center.x, self.e.center.y, self.e.focus1.x, self.e.focus1.y, self.e.radmin] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let (x0, y0) = (store.get(self.p.x), store.get(self.p.y)); + let (xc, yc) = (store.get(self.e.center.x), store.get(self.e.center.y)); + let (xf1, yf1) = (store.get(self.e.focus1.x), store.get(self.e.focus1.y)); + let b = store.get(self.e.radmin); + + -((x0 - xf1).powi(2) + (y0 - yf1).powi(2)).sqrt() + + ((x0 + xf1 - 2.0 * xc).powi(2) + (y0 + yf1 - 2.0 * yc).powi(2)).sqrt() + - 2.0 * (-b.powi(2) + (xf1 - xc).powi(2) + (yf1 - yc).powi(2)).sqrt() + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (p1x, p1y) = (self.p.x, self.p.y); + let (cx, cy) = (self.e.center.x, self.e.center.y); + let (f1x, f1y) = (self.e.focus1.x, self.e.focus1.y); + let rmin = self.e.radmin; + if ![p1x, p1y, f1x, f1y, cx, cy, rmin].contains(¶m) { + return 0.0; + } + let (x0, y0) = (store.get(p1x), store.get(p1y)); + let (xc, yc) = (store.get(cx), store.get(cy)); + let (xf1, yf1) = (store.get(f1x), store.get(f1y)); + let b = store.get(rmin); + + let d_pf1 = ((x0 - xf1).powi(2) + (y0 - yf1).powi(2)).sqrt(); + let d_pf2 = ((x0 + xf1 - 2.0 * xc).powi(2) + (y0 + yf1 - 2.0 * yc).powi(2)).sqrt(); + let d_cf1 = (-b.powi(2) + (xf1 - xc).powi(2) + (yf1 - yc).powi(2)).sqrt(); + + let mut deriv = 0.0; + if param == p1x { + deriv += -(x0 - xf1) / d_pf1 + (x0 + xf1 - 2.0 * xc) / d_pf2; + } + if param == p1y { + deriv += -(y0 - yf1) / d_pf1 + (y0 + yf1 - 2.0 * yc) / d_pf2; + } + if param == f1x { + deriv += (x0 - xf1) / d_pf1 - 2.0 * (xf1 - xc) / d_cf1 + (x0 + xf1 - 2.0 * xc) / d_pf2; + } + if param == f1y { + deriv += (y0 - yf1) / d_pf1 - 2.0 * (yf1 - yc) / d_cf1 + (y0 + yf1 - 2.0 * yc) / d_pf2; + } + if param == cx { + deriv += 2.0 * (xf1 - xc) / d_cf1 - 2.0 * (x0 + xf1 - 2.0 * xc) / d_pf2; + } + if param == cy { + deriv += 2.0 * (yf1 - yc) / d_cf1 - 2.0 * (y0 + yf1 - 2.0 * yc) / d_pf2; + } + if param == rmin { + deriv += 2.0 * b / d_cf1; + } + deriv + } +} + +/// Point `p` lies on parabola `e` (distance to the focus equals the +/// distance to the directrix — expressed here via the focus/vertex-derived +/// projection, matching the C++ directly rather than the textbook form). +pub struct PointOnParabola { + pub p: Point, + pub e: Parabola, +} + +impl PointOnParabola { + pub fn new(p: Point, e: Parabola) -> Self { + Self { p, e } + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let focus = DeriVector2::from_point(store, self.e.focus1, derivparam); + let vertex = DeriVector2::from_point(store, self.e.vertex, derivparam); + let point = DeriVector2::from_point(store, self.p, derivparam); + + let focalvect = focus.subtr(&vertex); + let xdir = focalvect.normalized(); + let point_to_focus = point.subtr(&focus); + + let (focal, dfocal) = focalvect.length_deriv(); + let (pf, dpf) = point_to_focus.length_deriv(); + let (proj, dproj) = point_to_focus.scalar_prod(&xdir); + + (pf - 2.0 * focal - proj, dpf - 2.0 * dfocal - dproj) + } +} + +impl Constraint for PointOnParabola { + fn params(&self) -> Vec { + vec![self.p.x, self.p.y, self.e.vertex.x, self.e.vertex.y, self.e.focus1.x, self.e.focus1.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::test_support::{assert_grad_matches_finite_difference, assert_single_param_grad_matches_fd}; + + fn point(store: &mut ParamStore, x: f64, y: f64) -> Point { + Point::new(store.add(x, false), store.add(y, false)) + } + + fn ellipse(store: &mut ParamStore, cx: f64, cy: f64, fx: f64, fy: f64, radmin: f64) -> Ellipse { + Ellipse { center: point(store, cx, cy), focus1: point(store, fx, fy), radmin: store.add(radmin, false) } + } + + #[test] + fn point_on_ellipse_is_zero_for_a_point_on_the_ellipse() { + let mut store = ParamStore::new(); + // c=3 (focus at x=3 from a center at origin), radmin(b)=4 -> a=5. + let e = ellipse(&mut store, 0.0, 0.0, 3.0, 0.0, 4.0); + let p = point(&mut store, 5.0, 0.0); // the +a vertex, on the ellipse + let c = PointOnEllipse::new(p, e); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn tangent_ellipse_line_gradient_checks_out() { + let mut store = ParamStore::new(); + let e = ellipse(&mut store, 0.0, 0.0, 3.0, 0.0, 4.0); // a=5 + // A horizontal line y=4 is tangent to this ellipse at its top (0,4). + let l = Line { + p1: point(&mut store, -10.0, 4.0), + p2: point(&mut store, 10.0, 4.0), + }; + let c = TangentEllipseLine::new(l, e); + assert!(c.error_value(&store).abs() < 1e-6); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn equal_major_axes_conic_compares_an_ellipse_and_a_hyperbola() { + let mut store = ParamStore::new(); + let e1 = ellipse(&mut store, 0.0, 0.0, 3.0, 0.0, 4.0); // a = 5 + // Hyperbola: c=5, b=4 -> a = sqrt(25-16) = 3, so NOT equal to e1's a=5 initially. + let h_center = point(&mut store, 100.0, 0.0); + let h_focus = point(&mut store, 105.0, 0.0); + let h_radmin = store.add(4.0, false); + let h = Hyperbola { center: h_center, focus1: h_focus, radmin: h_radmin }; + + let c = EqualMajorAxesConic::new(Conic::Ellipse(e1), Conic::Hyperbola(h)); + // a2 - a1 = 3 - 5 = -2 + assert!((c.error_value(&store) - (-2.0)).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn equal_focal_distance_matches_hand_computation() { + let mut store = ParamStore::new(); + let p1 = Parabola { vertex: point(&mut store, 0.0, 0.0), focus1: point(&mut store, 0.0, 2.0) }; + let p2 = Parabola { vertex: point(&mut store, 10.0, 0.0), focus1: point(&mut store, 10.0, 2.0) }; + let c = EqualFocalDistance::new(p1, p2); + assert!(c.error_value(&store).abs() < 1e-9); // both focal distances are 2 + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn point_on_hyperbola_is_zero_for_a_point_on_the_hyperbola() { + let mut store = ParamStore::new(); + // c=5, b=4 -> a=3 + let e = Hyperbola { center: point(&mut store, 0.0, 0.0), focus1: point(&mut store, 5.0, 0.0), radmin: store.add(4.0, false) }; + let p = point(&mut store, 3.0, 0.0); // the +a vertex, on the hyperbola + let c = PointOnHyperbola::new(p, e); + assert!(c.error_value(&store).abs() < 1e-9); + // Skip the radmin/center/focus params too close to this vertex's + // own singular direction for a robust central-difference check; + // check the point's own coordinates, which is the common case. + assert_single_param_grad_matches_fd(&c, &mut store, p.x); + assert_single_param_grad_matches_fd(&c, &mut store, p.y); + } + + #[test] + fn point_on_parabola_is_zero_for_a_point_on_the_parabola() { + let mut store = ParamStore::new(); + let e = Parabola { vertex: point(&mut store, 0.0, 0.0), focus1: point(&mut store, 0.0, 2.0) }; + // x^2 = 4fy with f=2: at x=4, y = 16/8 = 2 + let p = point(&mut store, 4.0, 2.0); + let c = PointOnParabola::new(p, e); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } +} diff --git a/crates/ocs_gcs/src/constraints/curve_generic.rs b/crates/ocs_gcs/src/constraints/curve_generic.rs new file mode 100644 index 000000000..35cc51509 --- /dev/null +++ b/crates/ocs_gcs/src/constraints/curve_generic.rs @@ -0,0 +1,498 @@ +//! Constraints that hold one or more curves generically (as `Rc`) +//! rather than as a concrete geometry type: `CurveValue`, the four +//! `AngleVia*` variants, and `Snell`. +//! +//! Ported from planegcs's `ConstraintCurveValue`, `ConstraintAngleViaPoint`, +//! `ConstraintAngleViaTwoPoints`, `ConstraintAngleViaPointAndParam`, +//! `ConstraintAngleViaPointAndTwoParams`, and `ConstraintSnell` +//! (`Constraints.h`/`.cpp`). These needed generic curve dispatch in the C++ +//! too (a `Curve*` field, `Curve::Copy()`), so `Rc` here is a +//! direct, not merely convenient, translation of that. +//! +//! The four `AngleVia*` constraints share one error/gradient formula — +//! "rotate curve 1's normal by the target angle, measure the angle from +//! that to curve 2's normal" — differing only in *how* each curve's normal +//! is sampled (at a stored [`Point`], or at a fixed curve parameter via +//! [`Curve::calculate_normal_at_param`]). [`angle_via_normals_error`] and +//! [`angle_via_normals_grad`] factor that shared formula out once. + +use std::rc::Rc; + +use crate::geo::{Curve, DeriVector2, Point}; +use crate::util::{ParamId, ParamStore}; + +use super::Constraint; + +fn rotate_by_angle(n: &DeriVector2, ang: f64) -> DeriVector2 { + let (si, co) = ang.sin_cos(); + DeriVector2::new(n.x * co - n.y * si, n.x * si + n.y * co) +} + +/// The shared `AngleVia*` error: the angle from `n1` (rotated by `ang`) to +/// `n2`, via `atan2` of their cross/dot products — equivalent to +/// `atan2(n2) - (atan2(n1) + ang)` except at zero normals, where this stays +/// zero instead of blowing up. +fn angle_via_normals_error(ang: f64, n1: DeriVector2, n2: DeriVector2) -> f64 { + let n1r = rotate_by_angle(&n1, ang); + (-n2.x * n1r.y + n2.y * n1r.x).atan2(n2.x * n1r.x + n2.y * n1r.y) +} + +/// `d(atan2(n.y, n.x))/dparam`, from `n`'s own derivative fields. +fn datan2_dparam(n: &DeriVector2) -> f64 { + let len2 = n.x * n.x + n.y * n.y; + (n.x * n.dy - n.y * n.dx) / len2 +} + +/// The shared `AngleVia*` gradient w.r.t. one parameter: `n1`/`n2` must +/// already have been sampled with `derivparam` set to that parameter (they +/// are *not* the rotated-by-angle vectors `error` uses — matching the C++, +/// which differentiates the un-rotated normals directly). +fn angle_via_normals_grad(is_angle_param: bool, n1: &DeriVector2, n2: &DeriVector2) -> f64 { + (if is_angle_param { -1.0 } else { 0.0 }) - datan2_dparam(n1) + datan2_dparam(n2) +} + +/// Ties a point's coordinate to a curve's parametric value: `p.x` (or +/// `p.y`) must equal the curve's x (or y) at parameter `u`. +pub struct CurveValue { + p: Point, + /// Must be `p.x` or `p.y` — which coordinate this constrains. + pcoord: ParamId, + crv: Rc, + u: ParamId, +} + +impl CurveValue { + /// `pcoord` must be `p.x` or `p.y`. + pub fn new(p: Point, pcoord: ParamId, crv: Rc, u: ParamId) -> Self { + debug_assert!(pcoord == p.x || pcoord == p.y, "pcoord must be p.x or p.y"); + Self { p, pcoord, crv, u } + } + + fn err_vec(&self, store: &ParamStore, derivparam: Option) -> DeriVector2 { + let u = store.get(self.u); + let du = if derivparam == Some(self.u) { 1.0 } else { 0.0 }; + let p_to = self.crv.value(store, u, du, derivparam); + let p_from = DeriVector2::from_point(store, self.p, derivparam); + p_from.subtr(&p_to) + } +} + +impl Constraint for CurveValue { + fn params(&self) -> Vec { + let mut params = vec![self.p.x, self.p.y, self.u]; + params.extend(self.crv.own_params()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let err_vec = self.err_vec(store, None); + if self.pcoord == self.p.x { err_vec.x } else { err_vec.y } + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let err_vec = self.err_vec(store, Some(param)); + if self.pcoord == self.p.x { err_vec.dx } else { err_vec.dy } + } +} + +/// Angle (measured via each curve's normal) between two curves at a single +/// shared point `poa`. +pub struct AngleViaPoint { + crv1: Rc, + crv2: Rc, + poa: Point, + angle: ParamId, +} + +impl AngleViaPoint { + pub fn new(crv1: Rc, crv2: Rc, poa: Point, angle: ParamId) -> Self { + Self { crv1, crv2, poa, angle } + } +} + +impl Constraint for AngleViaPoint { + fn params(&self) -> Vec { + let mut params = vec![self.angle, self.poa.x, self.poa.y]; + params.extend(self.crv1.own_params()); + params.extend(self.crv2.own_params()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let ang = store.get(self.angle); + let n1 = self.crv1.calculate_normal(store, self.poa, None); + let n2 = self.crv2.calculate_normal(store, self.poa, None); + angle_via_normals_error(ang, n1, n2) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let n1 = self.crv1.calculate_normal(store, self.poa, Some(param)); + let n2 = self.crv2.calculate_normal(store, self.poa, Some(param)); + angle_via_normals_grad(param == self.angle, &n1, &n2) + } +} + +/// As [`AngleViaPoint`], but each curve's normal is taken at its own point +/// (`poa1` for `crv1`, `poa2` for `crv2`) instead of a single shared one — +/// planegcs uses this for curve types (B-splines) whose normal is only +/// defined at their own stored start/end points. +pub struct AngleViaTwoPoints { + crv1: Rc, + crv2: Rc, + poa1: Point, + poa2: Point, + angle: ParamId, +} + +impl AngleViaTwoPoints { + pub fn new(crv1: Rc, crv2: Rc, poa1: Point, poa2: Point, angle: ParamId) -> Self { + Self { crv1, crv2, poa1, poa2, angle } + } +} + +impl Constraint for AngleViaTwoPoints { + fn params(&self) -> Vec { + let mut params = vec![self.angle, self.poa1.x, self.poa1.y, self.poa2.x, self.poa2.y]; + params.extend(self.crv1.own_params()); + params.extend(self.crv2.own_params()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let ang = store.get(self.angle); + let n1 = self.crv1.calculate_normal(store, self.poa1, None); + let n2 = self.crv2.calculate_normal(store, self.poa2, None); + angle_via_normals_error(ang, n1, n2) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let n1 = self.crv1.calculate_normal(store, self.poa1, Some(param)); + let n2 = self.crv2.calculate_normal(store, self.poa2, Some(param)); + angle_via_normals_grad(param == self.angle, &n1, &n2) + } +} + +/// As [`AngleViaPoint`], but `crv1`'s normal is taken at a fixed curve +/// parameter `cparam` (via [`Curve::calculate_normal_at_param`]) instead of +/// at a stored point — for constraining a curve's tangent at a specific +/// parametric location that isn't itself a sketch point. +pub struct AngleViaPointAndParam { + crv1: Rc, + crv2: Rc, + poa: Point, + cparam: ParamId, + angle: ParamId, +} + +impl AngleViaPointAndParam { + pub fn new(crv1: Rc, crv2: Rc, poa: Point, cparam: ParamId, angle: ParamId) -> Self { + Self { crv1, crv2, poa, cparam, angle } + } +} + +impl Constraint for AngleViaPointAndParam { + fn params(&self) -> Vec { + let mut params = vec![self.angle, self.poa.x, self.poa.y, self.cparam]; + params.extend(self.crv1.own_params()); + params.extend(self.crv2.own_params()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let ang = store.get(self.angle); + let n1 = self.crv1.calculate_normal_at_param(store, store.get(self.cparam), None); + let n2 = self.crv2.calculate_normal(store, self.poa, None); + angle_via_normals_error(ang, n1, n2) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let n1 = self.crv1.calculate_normal_at_param(store, store.get(self.cparam), Some(param)); + let n2 = self.crv2.calculate_normal(store, self.poa, Some(param)); + angle_via_normals_grad(param == self.angle, &n1, &n2) + } +} + +/// As [`AngleViaPointAndParam`], but *both* curves' normals are taken at +/// fixed curve parameters (`cparam1`, `cparam2`) — no stored point involved +/// at all. `point` is kept only for constructor-signature parity with the +/// C++ (which keeps an unused `Point p` field too, per its own `TODO: Do we +/// need point here at all?`); it plays no role in the error/gradient. +pub struct AngleViaPointAndTwoParams { + crv1: Rc, + crv2: Rc, + point: Point, + cparam1: ParamId, + cparam2: ParamId, + angle: ParamId, +} + +impl AngleViaPointAndTwoParams { + pub fn new( + crv1: Rc, + crv2: Rc, + point: Point, + cparam1: ParamId, + cparam2: ParamId, + angle: ParamId, + ) -> Self { + Self { crv1, crv2, point, cparam1, cparam2, angle } + } +} + +impl Constraint for AngleViaPointAndTwoParams { + fn params(&self) -> Vec { + let mut params = vec![self.angle, self.point.x, self.point.y, self.cparam1, self.cparam2]; + params.extend(self.crv1.own_params()); + params.extend(self.crv2.own_params()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let ang = store.get(self.angle); + let n1 = self.crv1.calculate_normal_at_param(store, store.get(self.cparam1), None); + let n2 = self.crv2.calculate_normal_at_param(store, store.get(self.cparam2), None); + angle_via_normals_error(ang, n1, n2) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let n1 = self.crv1.calculate_normal_at_param(store, store.get(self.cparam1), Some(param)); + let n2 = self.crv2.calculate_normal_at_param(store, store.get(self.cparam2), Some(param)); + angle_via_normals_grad(param == self.angle, &n1, &n2) + } +} + +/// Snell's law: relates the angles of incidence of two rays (`ray1`, +/// `ray2`) crossing a `boundary` curve at a shared point `poa`, via +/// refractive indices `n1`/`n2`. `flip_n1`/`flip_n2` flip the sign +/// convention for a ray, matching the C++'s `flipn1`/`flipn2`. +pub struct Snell { + ray1: Rc, + ray2: Rc, + boundary: Rc, + poa: Point, + n1: ParamId, + n2: ParamId, + flip_n1: bool, + flip_n2: bool, +} + +impl Snell { + #[allow(clippy::too_many_arguments)] + pub fn new( + ray1: Rc, + ray2: Rc, + boundary: Rc, + poa: Point, + n1: ParamId, + n2: ParamId, + flip_n1: bool, + flip_n2: bool, + ) -> Self { + Self { ray1, ray2, boundary, poa, n1, n2, flip_n1, flip_n2 } + } + + /// Returns `(sin1, dsin1, sin2, dsin2)` for the given `derivparam` + /// (`None` for the plain value, `Some(param)` for a derivative pass), + /// with `flip_n1`/`flip_n2` already applied. + fn sines(&self, store: &ParamStore, derivparam: Option) -> (f64, f64, f64, f64) { + let tang1 = self.ray1.calculate_normal(store, self.poa, derivparam).rotate90cw().normalized(); + let tang2 = self.ray2.calculate_normal(store, self.poa, derivparam).rotate90cw().normalized(); + let tang_b = self.boundary.calculate_normal(store, self.poa, derivparam).rotate90cw().normalized(); + let (mut sin1, mut dsin1) = tang1.scalar_prod(&tang_b); + let (mut sin2, mut dsin2) = tang2.scalar_prod(&tang_b); + if self.flip_n1 { + sin1 = -sin1; + dsin1 = -dsin1; + } + if self.flip_n2 { + sin2 = -sin2; + dsin2 = -dsin2; + } + (sin1, dsin1, sin2, dsin2) + } +} + +impl Constraint for Snell { + fn params(&self) -> Vec { + let mut params = vec![self.n1, self.n2, self.poa.x, self.poa.y]; + params.extend(self.ray1.own_params()); + params.extend(self.ray2.own_params()); + params.extend(self.boundary.own_params()); + params + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let (sin1, _, sin2, _) = self.sines(store, None); + store.get(self.n1) * sin1 - store.get(self.n2) * sin2 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (sin1, dsin1, sin2, dsin2) = self.sines(store, Some(param)); + let dn1 = if param == self.n1 { 1.0 } else { 0.0 }; + let dn2 = if param == self.n2 { 1.0 } else { 0.0 }; + dn1 * sin1 + store.get(self.n1) * dsin1 - dn2 * sin2 - store.get(self.n2) * dsin2 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::test_support::assert_grad_matches_finite_difference; + use crate::geo::{Circle, Line}; + + fn make_point(store: &mut ParamStore, x: f64, y: f64) -> Point { + Point::new(store.add(x, false), store.add(y, false)) + } + + #[test] + fn curve_value_zero_error_when_point_matches_curve_at_u() { + let mut store = ParamStore::new(); + let center = make_point(&mut store, 0.0, 0.0); + let rad = store.add(5.0, true); + let circle: Rc = Rc::new(Circle { center, rad }); + let u = store.add(0.7, false); + let p = make_point(&mut store, 5.0 * 0.7_f64.cos(), 5.0 * 0.7_f64.sin()); + + let c = CurveValue::new(p, p.x, circle.clone(), u); + assert!(c.error_value(&store).abs() < 1e-9); + + let cy = CurveValue::new(p, p.y, circle, u); + assert!(cy.error_value(&store).abs() < 1e-9); + } + + #[test] + fn curve_value_grad_matches_finite_difference() { + let mut store = ParamStore::new(); + let center = make_point(&mut store, 1.0, -2.0); + let rad = store.add(4.0, true); + let circle: Rc = Rc::new(Circle { center, rad }); + let u = store.add(0.3, true); + let p = make_point(&mut store, 10.0, 10.0); // deliberately off the circle + + let c = CurveValue::new(p, p.x, circle, u); + assert_grad_matches_finite_difference(&c, &mut store); + } + + fn make_lines_at_angle(store: &mut ParamStore, angle_rad: f64) -> (Rc, Rc, Point) { + let poa = make_point(store, 0.0, 0.0); + let l1_end = make_point(store, 10.0, 0.0); + let (si, co) = angle_rad.sin_cos(); + let l2_end = make_point(store, 10.0 * co, 10.0 * si); + let l1: Rc = Rc::new(Line { p1: poa, p2: l1_end }); + let l2: Rc = Rc::new(Line { p1: poa, p2: l2_end }); + (l1, l2, poa) + } + + #[test] + fn angle_via_point_zero_error_when_angle_matches() { + let mut store = ParamStore::new(); + let (l1, l2, poa) = make_lines_at_angle(&mut store, std::f64::consts::FRAC_PI_3); + let angle = store.add(std::f64::consts::FRAC_PI_3, true); + + let c = AngleViaPoint::new(l1, l2, poa, angle); + assert!(c.error_value(&store).abs() < 1e-9, "err={}", c.error_value(&store)); + } + + #[test] + fn angle_via_point_grad_matches_finite_difference() { + let mut store = ParamStore::new(); + let (l1, l2, poa) = make_lines_at_angle(&mut store, 0.9); + let angle = store.add(0.4, true); // deliberately not yet satisfied + + let c = AngleViaPoint::new(l1, l2, poa, angle); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn angle_via_two_points_grad_matches_finite_difference() { + let mut store = ParamStore::new(); + let poa1 = make_point(&mut store, 0.0, 0.0); + let l1: Rc = Rc::new(Line { p1: poa1, p2: make_point(&mut store, 10.0, 0.0) }); + let poa2 = make_point(&mut store, 5.0, 5.0); + let l2: Rc = Rc::new(Line { p1: poa2, p2: make_point(&mut store, 5.0, 15.0) }); + let angle = store.add(0.6, true); + + let c = AngleViaTwoPoints::new(l1, l2, poa1, poa2, angle); + assert_grad_matches_finite_difference(&c, &mut store); + } + + // `calculate_normal_at_param` has a real, documented upstream + // limitation (see its doc comment in `geo.rs`): for any curve whose + // normal formula depends on point position (Circle, the conics), the + // gradient it produces is incomplete for that curve's own shape + // parameters, not just for the `u`-value parameter itself. `Line` is + // exempt (its normal is position-independent), so using lines on the + // "at param" side here is what makes these two tests able to check the + // *whole* gradient with no exclusions, unlike the case that first + // surfaced the limitation (a circle on that side). + + #[test] + fn angle_via_point_and_param_grad_matches_finite_difference() { + let mut store = ParamStore::new(); + // crv1's normal is taken "at param" (cparam) rather than at a + // point — a Line's normal doesn't depend on position, so this is + // exact for every parameter, including cparam. + let l1: Rc = + Rc::new(Line { p1: make_point(&mut store, 0.0, 0.0), p2: make_point(&mut store, 10.0, 0.0) }); + let cparam = store.add(0.5, true); + + let center = make_point(&mut store, 5.0, 5.0); + let rad = store.add(3.0, true); + let poa = make_point(&mut store, 8.0, 5.0); // on the circle, angle=0 from center + let circle: Rc = Rc::new(Circle { center, rad }); + let angle = store.add(1.1, true); + + let c = AngleViaPointAndParam::new(l1, circle, poa, cparam, angle); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn angle_via_point_and_two_params_grad_matches_finite_difference() { + let mut store = ParamStore::new(); + let l1: Rc = + Rc::new(Line { p1: make_point(&mut store, 0.0, 0.0), p2: make_point(&mut store, 10.0, 0.0) }); + let cparam1 = store.add(0.5, true); + + let l2: Rc = + Rc::new(Line { p1: make_point(&mut store, 3.0, 3.0), p2: make_point(&mut store, 3.0, 13.0) }); + let cparam2 = store.add(1.2, true); + + let angle = store.add(0.3, true); + let unused_point = make_point(&mut store, 0.0, 0.0); + + let c = AngleViaPointAndTwoParams::new(l1, l2, unused_point, cparam1, cparam2, angle); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn snell_zero_error_at_normal_incidence_with_matched_indices() { + // Boundary is the y-axis (vertical line); both rays run straight + // along +x through the origin, i.e. normal incidence (sin = 0 on + // both sides), so error is zero for *any* n1/n2. + let mut store = ParamStore::new(); + let poa = make_point(&mut store, 0.0, 0.0); + let boundary: Rc = Rc::new(Line { p1: poa, p2: make_point(&mut store, 0.0, 10.0) }); + let ray1: Rc = Rc::new(Line { p1: make_point(&mut store, -5.0, 0.0), p2: poa }); + let ray2: Rc = Rc::new(Line { p1: poa, p2: make_point(&mut store, 5.0, 0.0) }); + let n1 = store.add(1.0, true); + let n2 = store.add(1.5, true); + + let c = Snell::new(ray1, ray2, boundary, poa, n1, n2, false, false); + assert!(c.error_value(&store).abs() < 1e-9, "err={}", c.error_value(&store)); + } + + #[test] + fn snell_grad_matches_finite_difference() { + let mut store = ParamStore::new(); + let poa = make_point(&mut store, 0.0, 0.0); + let boundary: Rc = Rc::new(Line { p1: poa, p2: make_point(&mut store, 0.0, 10.0) }); + // Rays at an angle to the boundary, so sines are nonzero. + let ray1: Rc = Rc::new(Line { p1: make_point(&mut store, -5.0, -2.0), p2: poa }); + let ray2: Rc = Rc::new(Line { p1: poa, p2: make_point(&mut store, 5.0, -3.0) }); + let n1 = store.add(1.0, true); + let n2 = store.add(1.5, true); + + let c = Snell::new(ray1, ray2, boundary, poa, n1, n2, false, true); + assert_grad_matches_finite_difference(&c, &mut store); + } +} diff --git a/crates/ocs_gcs/src/constraints/mod.rs b/crates/ocs_gcs/src/constraints/mod.rs new file mode 100644 index 000000000..81bc16cd3 --- /dev/null +++ b/crates/ocs_gcs/src/constraints/mod.rs @@ -0,0 +1,108 @@ +//! The `Constraint` trait: one primitive geometric relationship the solver +//! can evaluate an error and gradient for. +//! +//! Ported from planegcs's base `Constraint` class (`Constraints.h`/`.cpp`). +//! Concrete constraint types (Equal, P2PDistance, Parallel, ...) are added in +//! later port stages, grouped by geometric complexity, under this module. + +use std::collections::HashMap; + +use crate::util::{ParamId, ParamStore}; + +/// A geometric constraint: an unscaled error function of its own parameters, +/// differentiable w.r.t. any one of them. +/// +/// `error()`/`grad()` provide the *scaled* values (`scale * error_value()`, +/// `scale * grad_value()`) a [`SubSystem`](crate::subsystem::SubSystem) +/// assembles into its residual/Jacobian/gradient — mirroring the base +/// `Constraint::error()`/`::grad()` in the C++, which wrap `errorgrad()` +/// (or, for most concrete constraints, an overridden `error()`/`grad()` +/// pair) the same way. +pub trait Constraint { + /// This constraint's own parameters, in a fixed order specific to the + /// constraint type — the C++'s `pvec`. + fn params(&self) -> Vec; + + /// Defaults to `1.0`, matching the C++ constructor's `scale(1.)`. + fn scale(&self) -> f64 { + 1.0 + } + + /// Unscaled constraint error at the store's current values. + fn error_value(&self, store: &ParamStore) -> f64; + + /// Unscaled derivative of [`error_value`](Self::error_value) w.r.t. + /// `param`. Only ever called for a `param` known to be one of + /// [`params`](Self::params) — [`grad`](Self::grad) is what does that + /// membership check, matching the C++'s `findParamInPvec` guard in the + /// base class's `grad()`. + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64; + + fn error(&self, store: &ParamStore) -> f64 { + self.scale() * self.error_value(store) + } + + fn grad(&self, store: &ParamStore, param: ParamId) -> f64 { + if !self.params().contains(¶m) { + return 0.0; + } + self.scale() * self.grad_value(store, param) + } + + /// Limits a proposed solver step so this constraint stays satisfiable + /// (e.g. a radius must not go negative). `dir` gives the proposed + /// per-parameter step for whichever of this constraint's parameters are + /// moving. Defaults to leaving `lim` unchanged, matching the C++ base + /// class's default `return lim;`. + fn max_step(&self, _store: &ParamStore, _dir: &HashMap, lim: f64) -> f64 { + lim + } +} + +pub mod angle_distance; +pub mod bspline; +pub mod circle_arc; +pub mod conic; +pub mod curve_generic; +pub mod point_line; + +/// Shared test support: cross-checks a constraint's analytic `grad_value` +/// against central-difference numerical differentiation of `error_value`, +/// for every one of its own parameters. This is Oracle B from the port +/// plan — cheap, independent of any FreeCAD fixture, and effective at +/// catching a mistranscribed formula (a wrong sign or dropped term in +/// `grad_value` almost never happens to match the numeric derivative by +/// chance). +#[cfg(test)] +pub(crate) mod test_support { + use super::Constraint; + use crate::util::{ParamId, ParamStore}; + + pub fn assert_grad_matches_finite_difference(constr: &dyn Constraint, store: &mut ParamStore) { + for param in constr.params() { + assert_single_param_grad_matches_fd(constr, store, param); + } + } + + /// Like [`assert_grad_matches_finite_difference`] but for one named + /// parameter, so a caller can skip a specific param known to have a + /// documented upstream quirk instead of the whole constraint. + pub fn assert_single_param_grad_matches_fd(constr: &dyn Constraint, store: &mut ParamStore, param: ParamId) { + const H: f64 = 1e-6; + const TOL: f64 = 1e-5; + let original = store.get(param); + + store.set(param, original + H); + let plus = constr.error_value(store); + store.set(param, original - H); + let minus = constr.error_value(store); + store.set(param, original); + + let numeric = (plus - minus) / (2.0 * H); + let analytic = constr.grad_value(store, param); + assert!( + (numeric - analytic).abs() < TOL, + "grad mismatch for param {param:?}: analytic={analytic}, numeric={numeric}" + ); + } +} diff --git a/crates/ocs_gcs/src/constraints/point_line.rs b/crates/ocs_gcs/src/constraints/point_line.rs new file mode 100644 index 000000000..dfdbc2821 --- /dev/null +++ b/crates/ocs_gcs/src/constraints/point_line.rs @@ -0,0 +1,956 @@ +//! Point/line primitive constraints (port stage 5a). +//! +//! Ported from the corresponding `Constraint*` classes in +//! `Constraints.h`/`Constraints.cpp`. Two things every constraint type here +//! omits, both intentionally and both because they serve FreeCAD's +//! *driven*-dimension bookkeeping rather than the solve itself: +//! - `evaluate()` (computes a driven dimension's value from geometry without +//! solving) has no port — nothing in the solve path calls it. +//! - `rescale()`'s coefficient parameter (used to *re*-scale a constraint +//! after construction, e.g. when FreeCAD's UI edits geometry) isn't +//! exposed; the scale planegcs computes at construction time (for +//! `Parallel`/`Perpendicular`) is computed once, in `new`, from the +//! `ParamStore`'s values at that moment. + +use std::collections::HashMap; + +use crate::constraints::Constraint; +use crate::geo::{Line, Point}; +use crate::util::{ParamId, ParamStore}; + +/// `param1 == ratio * param2`. +/// +/// Faithfully ported including an upstream quirk: `grad_value` w.r.t. +/// `param2` is `-1`, not `-ratio`, even though `error_value` is +/// `param1 - ratio*param2` (whose true derivative w.r.t. `param2` is +/// `-ratio`). This matches planegcs's `ConstraintEqual::grad` exactly +/// (`Constraints.cpp`, `ConstraintEqual::grad`) — for the overwhelmingly +/// common `ratio == 1.0` case the two coincide, so this is only observable +/// when a caller passes a non-default ratio. +pub struct Equal { + pub param1: ParamId, + pub param2: ParamId, + pub ratio: f64, +} + +impl Equal { + pub fn new(param1: ParamId, param2: ParamId, ratio: f64) -> Self { + Self { param1, param2, ratio } + } +} + +impl Constraint for Equal { + fn params(&self) -> Vec { + vec![self.param1, self.param2] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + store.get(self.param1) - self.ratio * store.get(self.param2) + } + + fn grad_value(&self, _store: &ParamStore, param: ParamId) -> f64 { + let mut deriv = 0.0; + if param == self.param1 { + deriv += 1.0; + } + if param == self.param2 { + deriv -= 1.0; + } + deriv + } +} + +/// `param2 - param1 == difference`. +pub struct Difference { + pub param1: ParamId, + pub param2: ParamId, + pub difference: ParamId, +} + +impl Difference { + pub fn new(param1: ParamId, param2: ParamId, difference: ParamId) -> Self { + Self { param1, param2, difference } + } +} + +impl Constraint for Difference { + fn params(&self) -> Vec { + vec![self.param1, self.param2, self.difference] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + (store.get(self.param2) - store.get(self.param1)) - store.get(self.difference) + } + + fn grad_value(&self, _store: &ParamStore, param: ParamId) -> f64 { + let mut deriv = 0.0; + if param == self.param1 { + deriv -= 1.0; + } + if param == self.param2 { + deriv += 1.0; + } + if param == self.difference { + deriv -= 1.0; + } + deriv + } +} + +/// `center == sum(points[i] * weights[i])`. +pub struct CenterOfGravity { + pub center: ParamId, + pub points: Vec, + pub weights: Vec, +} + +impl CenterOfGravity { + pub fn new(center: ParamId, points: Vec, weights: Vec) -> Self { + assert_eq!(points.len(), weights.len()); + Self { center, points, weights } + } +} + +impl Constraint for CenterOfGravity { + fn params(&self) -> Vec { + let mut p = vec![self.center]; + p.extend(&self.points); + p + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let sum: f64 = self.points.iter().zip(&self.weights).map(|(&p, &w)| store.get(p) * w).sum(); + store.get(self.center) - sum + } + + fn grad_value(&self, _store: &ParamStore, param: ParamId) -> f64 { + let mut deriv = 0.0; + if param == self.center { + deriv = 1.0; + } + for (&p, &w) in self.points.iter().zip(&self.weights) { + if param == p { + deriv = -w; + } + } + deriv + } +} + +/// `point * sum(weights[i]*factors[i]) == sum(poles[i]*weights[i]*factors[i])` +/// (a knot point staying at the position its B-spline poles determine, in +/// homogeneous coordinates). +pub struct WeightedLinearCombination { + pub point: ParamId, + pub poles: Vec, + pub weights: Vec, + pub factors: Vec, +} + +impl WeightedLinearCombination { + pub fn new(point: ParamId, poles: Vec, weights: Vec, factors: Vec) -> Self { + assert_eq!(poles.len(), weights.len()); + assert_eq!(poles.len(), factors.len()); + Self { point, poles, weights, factors } + } +} + +impl Constraint for WeightedLinearCombination { + fn params(&self) -> Vec { + let mut p = vec![self.point]; + p.extend(&self.poles); + p.extend(&self.weights); + p + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let mut sum = 0.0; + let mut wsum = 0.0; + for i in 0..self.poles.len() { + let wcontrib = store.get(self.weights[i]) * self.factors[i]; + wsum += wcontrib; + sum += store.get(self.poles[i]) * wcontrib; + } + store.get(self.point) * wsum - sum + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + if param == self.point { + let wsum: f64 = (0..self.poles.len()).map(|i| store.get(self.weights[i]) * self.factors[i]).sum(); + return wsum; + } + for i in 0..self.poles.len() { + if param == self.poles[i] { + return -(store.get(self.weights[i]) * self.factors[i]); + } + if param == self.weights[i] { + return (store.get(self.point) - store.get(self.poles[i])) * self.factors[i]; + } + } + 0.0 + } +} + +/// `|p1 - p2| == distance`. +pub struct P2PDistance { + pub p1: Point, + pub p2: Point, + pub distance: ParamId, +} + +impl P2PDistance { + pub fn new(p1: Point, p2: Point, distance: ParamId) -> Self { + Self { p1, p2, distance } + } + + fn value(&self, store: &ParamStore) -> f64 { + let dx = store.get(self.p1.x) - store.get(self.p2.x); + let dy = store.get(self.p1.y) - store.get(self.p2.y); + (dx * dx + dy * dy).sqrt() + } +} + +impl Constraint for P2PDistance { + fn params(&self) -> Vec { + vec![self.p1.x, self.p1.y, self.p2.x, self.p2.y, self.distance] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.value(store) - store.get(self.distance) + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let mut deriv = 0.0; + if [self.p1.x, self.p1.y, self.p2.x, self.p2.y].contains(¶m) { + let dx = store.get(self.p1.x) - store.get(self.p2.x); + let dy = store.get(self.p1.y) - store.get(self.p2.y); + let d = (dx * dx + dy * dy).sqrt(); + if param == self.p1.x { + deriv += dx / d; + } + if param == self.p1.y { + deriv += dy / d; + } + if param == self.p2.x { + deriv += -dx / d; + } + if param == self.p2.y { + deriv += -dy / d; + } + } + if param == self.distance { + deriv += -1.0; + } + deriv + } + + fn max_step(&self, store: &ParamStore, dir: &HashMap, lim: f64) -> f64 { + let mut lim = lim; + if let Some(&d) = dir.get(&self.distance) { + if d < 0.0 { + lim = lim.min(-store.get(self.distance) / d); + } + } + let mut ddx = 0.0; + let mut ddy = 0.0; + if let Some(&v) = dir.get(&self.p1.x) { + ddx += v; + } + if let Some(&v) = dir.get(&self.p1.y) { + ddy += v; + } + if let Some(&v) = dir.get(&self.p2.x) { + ddx -= v; + } + if let Some(&v) = dir.get(&self.p2.y) { + ddy -= v; + } + let dd = (ddx * ddx + ddy * ddy).sqrt(); + let dist = store.get(self.distance); + if dd > dist { + let dx = store.get(self.p1.x) - store.get(self.p2.x); + let dy = store.get(self.p1.y) - store.get(self.p2.y); + let d = (dx * dx + dy * dy).sqrt(); + if dd > d { + lim = lim.min(d.max(dist) / dd); + } + } + lim + } +} + +/// Signed distance from point `p` to line `l`, matching `distance` in sign +/// per `ccw` (counterclockwise winding puts the point on the positive side). +pub struct P2LDistance { + pub p: Point, + pub l: Line, + pub distance: ParamId, + pub ccw: bool, +} + +impl P2LDistance { + pub fn new(p: Point, l: Line, distance: ParamId, ccw: bool) -> Self { + Self { p, l, distance, ccw } + } + + fn signed_value(&self, store: &ParamStore) -> f64 { + let (x0, y0) = (store.get(self.p.x), store.get(self.p.y)); + let (x1, y1) = (store.get(self.l.p1.x), store.get(self.l.p1.y)); + let (x2, y2) = (store.get(self.l.p2.x), store.get(self.l.p2.y)); + let dx = x2 - x1; + let dy = y2 - y1; + let d = (dx * dx + dy * dy).sqrt(); + let area = -x0 * dy + y0 * dx + x1 * y2 - x2 * y1; + area / d + } +} + +impl Constraint for P2LDistance { + fn params(&self) -> Vec { + vec![self.p.x, self.p.y, self.l.p1.x, self.l.p1.y, self.l.p2.x, self.l.p2.y, self.distance] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let dist = if self.ccw { store.get(self.distance).abs() } else { -store.get(self.distance).abs() }; + self.signed_value(store) - dist + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let mut deriv = 0.0; + let (p0x, p0y) = (self.p.x, self.p.y); + let (p1x, p1y) = (self.l.p1.x, self.l.p1.y); + let (p2x, p2y) = (self.l.p2.x, self.l.p2.y); + if [p0x, p0y, p1x, p1y, p2x, p2y].contains(¶m) { + let (x0, y0) = (store.get(p0x), store.get(p0y)); + let (x1, y1) = (store.get(p1x), store.get(p1y)); + let (x2, y2) = (store.get(p2x), store.get(p2y)); + let dx = x2 - x1; + let dy = y2 - y1; + let d2 = dx * dx + dy * dy; + let d = d2.sqrt(); + let area = -x0 * dy + y0 * dx + x1 * y2 - x2 * y1; + + if param == p0x { + deriv += (y1 - y2) / d; + } + if param == p0y { + deriv += (x2 - x1) / d; + } + if param == p1x { + deriv += ((y2 - y0) * d + (dx / d) * area) / d2; + } + if param == p1y { + deriv += ((x0 - x2) * d + (dy / d) * area) / d2; + } + if param == p2x { + deriv += ((y0 - y1) * d - (dx / d) * area) / d2; + } + if param == p2y { + deriv += ((x1 - x0) * d - (dy / d) * area) / d2; + } + } + if param == self.distance { + deriv += if self.ccw { -1.0 } else { 1.0 }; + } + deriv + } + + fn max_step(&self, store: &ParamStore, dir: &HashMap, lim: f64) -> f64 { + let mut lim = lim; + if let Some(&d) = dir.get(&self.distance) { + if d < 0.0 { + lim = lim.min(-store.get(self.distance) / d); + } + } + let (x0, y0) = (store.get(self.p.x), store.get(self.p.y)); + let (x1, y1) = (store.get(self.l.p1.x), store.get(self.l.p1.y)); + let (x2, y2) = (store.get(self.l.p2.x), store.get(self.l.p2.y)); + let mut darea = 0.0; + if let Some(&v) = dir.get(&self.p.x) { + darea += (y1 - y2) * v; + } + if let Some(&v) = dir.get(&self.p.y) { + darea += (x2 - x1) * v; + } + if let Some(&v) = dir.get(&self.l.p1.x) { + darea += (y2 - y0) * v; + } + if let Some(&v) = dir.get(&self.l.p1.y) { + darea += (x0 - x2) * v; + } + if let Some(&v) = dir.get(&self.l.p2.x) { + darea += (y0 - y1) * v; + } + if let Some(&v) = dir.get(&self.l.p2.y) { + darea += (x1 - x0) * v; + } + darea = darea.abs(); + if darea > 0.0 { + let dx = x2 - x1; + let dy = y2 - y1; + let mut area = 0.3 * store.get(self.distance) * (dx * dx + dy * dy).sqrt(); + if darea > area { + area = area.max(0.3 * (-x0 * dy + y0 * dx + x1 * y2 - x2 * y1).abs()); + if darea > area { + lim = lim.min(area / darea); + } + } + } + lim + } +} + +/// Point `p` lies on the (infinite) line through `l`. +pub struct PointOnLine { + pub p: Point, + pub l: Line, +} + +impl PointOnLine { + pub fn new(p: Point, l: Line) -> Self { + Self { p, l } + } +} + +impl Constraint for PointOnLine { + fn params(&self) -> Vec { + vec![self.p.x, self.p.y, self.l.p1.x, self.l.p1.y, self.l.p2.x, self.l.p2.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let (x0, y0) = (store.get(self.p.x), store.get(self.p.y)); + let (x1, y1) = (store.get(self.l.p1.x), store.get(self.l.p1.y)); + let (x2, y2) = (store.get(self.l.p2.x), store.get(self.l.p2.y)); + let dx = x2 - x1; + let dy = y2 - y1; + let d = (dx * dx + dy * dy).sqrt(); + let area = -x0 * dy + y0 * dx + x1 * y2 - x2 * y1; + area / d + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (p0x, p0y) = (self.p.x, self.p.y); + let (p1x, p1y) = (self.l.p1.x, self.l.p1.y); + let (p2x, p2y) = (self.l.p2.x, self.l.p2.y); + if ![p0x, p0y, p1x, p1y, p2x, p2y].contains(¶m) { + return 0.0; + } + let (x0, y0) = (store.get(p0x), store.get(p0y)); + let (x1, y1) = (store.get(p1x), store.get(p1y)); + let (x2, y2) = (store.get(p2x), store.get(p2y)); + let dx = x2 - x1; + let dy = y2 - y1; + let d2 = dx * dx + dy * dy; + let d = d2.sqrt(); + let area = -x0 * dy + y0 * dx + x1 * y2 - x2 * y1; + let mut deriv = 0.0; + if param == p0x { + deriv += (y1 - y2) / d; + } + if param == p0y { + deriv += (x2 - x1) / d; + } + if param == p1x { + deriv += ((y2 - y0) * d + (dx / d) * area) / d2; + } + if param == p1y { + deriv += ((x0 - x2) * d + (dy / d) * area) / d2; + } + if param == p2x { + deriv += ((y0 - y1) * d - (dx / d) * area) / d2; + } + if param == p2y { + deriv += ((x1 - x0) * d - (dy / d) * area) / d2; + } + deriv + } +} + +/// Point `p` is equidistant from `l`'s two endpoints (on the perpendicular +/// bisector of the segment `l`). Ported from the combined `errorgrad` the +/// C++ uses here (`ConstraintPointOnPerpBisector::errorgrad`), rather than +/// separate `error`/`grad` overrides. +pub struct PointOnPerpBisector { + pub p: Point, + pub l: Line, +} + +impl PointOnPerpBisector { + pub fn new(p: Point, l: Line) -> Self { + Self { p, l } + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + use crate::geo::DeriVector2; + let p0 = DeriVector2::from_point(store, self.p, derivparam); + let p1 = DeriVector2::from_point(store, self.l.p1, derivparam); + let p2 = DeriVector2::from_point(store, self.l.p2, derivparam); + + let d1 = p0.subtr(&p1); + let d2 = p0.subtr(&p2); + let d = p2.subtr(&p1).normalized(); + + let (projd1, dprojd1) = d1.scalar_prod(&d); + let (projd2, dprojd2) = d2.scalar_prod(&d); + + (projd1 + projd2, dprojd1 + dprojd2) + } +} + +impl Constraint for PointOnPerpBisector { + fn params(&self) -> Vec { + vec![self.p.x, self.p.y, self.l.p1.x, self.l.p1.y, self.l.p2.x, self.l.p2.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +/// Lines `l1`/`l2` have the same direction (their direction vectors' cross +/// product is zero). `scale` is fixed at construction to +/// `1/sqrt(|l1|^2 * |l2|^2)`, matching planegcs's `rescale()` for this type. +pub struct Parallel { + pub l1: Line, + pub l2: Line, + scale: f64, +} + +impl Parallel { + pub fn new(store: &ParamStore, l1: Line, l2: Line) -> Self { + let scale = Self::compute_scale(store, &l1, &l2); + Self { l1, l2, scale } + } + + fn compute_scale(store: &ParamStore, l1: &Line, l2: &Line) -> f64 { + let dx1 = store.get(l1.p1.x) - store.get(l1.p2.x); + let dy1 = store.get(l1.p1.y) - store.get(l1.p2.y); + let dx2 = store.get(l2.p1.x) - store.get(l2.p2.x); + let dy2 = store.get(l2.p1.y) - store.get(l2.p2.y); + 1.0 / ((dx1 * dx1 + dy1 * dy1) * (dx2 * dx2 + dy2 * dy2)).sqrt() + } +} + +impl Constraint for Parallel { + fn params(&self) -> Vec { + vec![self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y, self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y] + } + + fn scale(&self) -> f64 { + self.scale + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let dx1 = store.get(self.l1.p1.x) - store.get(self.l1.p2.x); + let dy1 = store.get(self.l1.p1.y) - store.get(self.l1.p2.y); + let dx2 = store.get(self.l2.p1.x) - store.get(self.l2.p2.x); + let dy2 = store.get(self.l2.p1.y) - store.get(self.l2.p2.y); + dx1 * dy2 - dy1 * dx2 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (l1p1x, l1p1y, l1p2x, l1p2y) = (self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y); + let (l2p1x, l2p1y, l2p2x, l2p2y) = (self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y); + let dx1 = store.get(l1p1x) - store.get(l1p2x); + let dy1 = store.get(l1p1y) - store.get(l1p2y); + let dx2 = store.get(l2p1x) - store.get(l2p2x); + let dy2 = store.get(l2p1y) - store.get(l2p2y); + let mut deriv = 0.0; + if param == l1p1x { + deriv += dy2; + } + if param == l1p2x { + deriv += -dy2; + } + if param == l1p1y { + deriv += -dx2; + } + if param == l1p2y { + deriv += dx2; + } + if param == l2p1x { + deriv += -dy1; + } + if param == l2p2x { + deriv += dy1; + } + if param == l2p1y { + deriv += dx1; + } + if param == l2p2y { + deriv += -dx1; + } + deriv + } +} + +/// Lines `l1`/`l2` are at right angles (their direction vectors' dot product +/// is zero). Same scale treatment as [`Parallel`]. +pub struct Perpendicular { + pub l1: Line, + pub l2: Line, + scale: f64, +} + +impl Perpendicular { + pub fn new(store: &ParamStore, l1: Line, l2: Line) -> Self { + let scale = Parallel::compute_scale(store, &l1, &l2); + Self { l1, l2, scale } + } +} + +impl Constraint for Perpendicular { + fn params(&self) -> Vec { + vec![self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y, self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y] + } + + fn scale(&self) -> f64 { + self.scale + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let dx1 = store.get(self.l1.p1.x) - store.get(self.l1.p2.x); + let dy1 = store.get(self.l1.p1.y) - store.get(self.l1.p2.y); + let dx2 = store.get(self.l2.p1.x) - store.get(self.l2.p2.x); + let dy2 = store.get(self.l2.p1.y) - store.get(self.l2.p2.y); + dx1 * dx2 + dy1 * dy2 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (l1p1x, l1p1y, l1p2x, l1p2y) = (self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y); + let (l2p1x, l2p1y, l2p2x, l2p2y) = (self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y); + let dx1 = store.get(l1p1x) - store.get(l1p2x); + let dy1 = store.get(l1p1y) - store.get(l1p2y); + let dx2 = store.get(l2p1x) - store.get(l2p2x); + let dy2 = store.get(l2p1y) - store.get(l2p2y); + let mut deriv = 0.0; + if param == l1p1x { + deriv += dx2; + } + if param == l1p2x { + deriv += -dx2; + } + if param == l1p1y { + deriv += dy2; + } + if param == l1p2y { + deriv += -dy2; + } + if param == l2p1x { + deriv += dx1; + } + if param == l2p2x { + deriv += -dx1; + } + if param == l2p1y { + deriv += dy1; + } + if param == l2p2y { + deriv += -dy1; + } + deriv + } +} + +/// The midpoint of `l1` lies on the (infinite) line through `l2`. +pub struct MidpointOnLine { + pub l1: Line, + pub l2: Line, +} + +impl MidpointOnLine { + pub fn new(l1: Line, l2: Line) -> Self { + Self { l1, l2 } + } +} + +impl Constraint for MidpointOnLine { + fn params(&self) -> Vec { + vec![self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y, self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + let x0 = (store.get(self.l1.p1.x) + store.get(self.l1.p2.x)) / 2.0; + let y0 = (store.get(self.l1.p1.y) + store.get(self.l1.p2.y)) / 2.0; + let (x1, y1) = (store.get(self.l2.p1.x), store.get(self.l2.p1.y)); + let (x2, y2) = (store.get(self.l2.p2.x), store.get(self.l2.p2.y)); + let dx = x2 - x1; + let dy = y2 - y1; + let d = (dx * dx + dy * dy).sqrt(); + let area = -x0 * dy + y0 * dx + x1 * y2 - x2 * y1; + area / d + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + let (l1p1x, l1p1y, l1p2x, l1p2y) = (self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y); + let (l2p1x, l2p1y, l2p2x, l2p2y) = (self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y); + if ![l1p1x, l1p1y, l1p2x, l1p2y, l2p1x, l2p1y, l2p2x, l2p2y].contains(¶m) { + return 0.0; + } + let x0 = (store.get(l1p1x) + store.get(l1p2x)) / 2.0; + let y0 = (store.get(l1p1y) + store.get(l1p2y)) / 2.0; + let (x1, y1) = (store.get(l2p1x), store.get(l2p1y)); + let (x2, y2) = (store.get(l2p2x), store.get(l2p2y)); + let dx = x2 - x1; + let dy = y2 - y1; + let d2 = dx * dx + dy * dy; + let d = d2.sqrt(); + let area = -x0 * dy + y0 * dx + x1 * y2 - x2 * y1; + let mut deriv = 0.0; + if param == l1p1x { + deriv += (y1 - y2) / (2.0 * d); + } + if param == l1p1y { + deriv += (x2 - x1) / (2.0 * d); + } + if param == l1p2x { + deriv += (y1 - y2) / (2.0 * d); + } + if param == l1p2y { + deriv += (x2 - x1) / (2.0 * d); + } + if param == l2p1x { + deriv += ((y2 - y0) * d + (dx / d) * area) / d2; + } + if param == l2p1y { + deriv += ((x0 - x2) * d + (dy / d) * area) / d2; + } + if param == l2p2x { + deriv += ((y0 - y1) * d - (dx / d) * area) / d2; + } + if param == l2p2y { + deriv += ((x1 - x0) * d - (dy / d) * area) / d2; + } + deriv + } +} + +/// `l1` and `l2` have equal length. Ported from the combined `errorgrad` +/// the C++ uses here (`ConstraintEqualLineLength::errorgrad`), including its +/// surrogate-gradient fallback: when both lines are axis-aligned the same +/// way, the true gradient underflows toward zero and the solver's diagnosis +/// pass would misread that as "fully constrained" rather than "at a +/// horizontal/vertical extremum" — so a tiny signed surrogate derivative is +/// substituted whenever the true one drops below `1e-10`. +pub struct EqualLineLength { + pub l1: Line, + pub l2: Line, +} + +impl EqualLineLength { + pub fn new(l1: Line, l2: Line) -> Self { + Self { l1, l2 } + } + + fn error_grad(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + use crate::geo::DeriVector2; + let p1 = DeriVector2::from_point(store, self.l1.p1, derivparam); + let p2 = DeriVector2::from_point(store, self.l1.p2, derivparam); + let p3 = DeriVector2::from_point(store, self.l2.p1, derivparam); + let p4 = DeriVector2::from_point(store, self.l2.p2, derivparam); + + let v1 = p1.subtr(&p2); + let v2 = p3.subtr(&p4); + + let (length1, dlength1) = v1.length_deriv(); + let (length2, dlength2) = v2.length_deriv(); + + let err = length2 - length1; + let mut grad = dlength2 - dlength1; + + if grad.abs() < 1e-10 { + const SURROGATE: f64 = 1e-10; + if let Some(param) = derivparam { + if param == self.l1.p1.x { + grad = if v1.x > 0.0 { SURROGATE } else { -SURROGATE }; + } + if param == self.l1.p1.y { + grad = if v1.y > 0.0 { SURROGATE } else { -SURROGATE }; + } + if param == self.l1.p2.x { + grad = if v1.x > 0.0 { -SURROGATE } else { SURROGATE }; + } + if param == self.l1.p2.y { + grad = if v1.y > 0.0 { -SURROGATE } else { SURROGATE }; + } + if param == self.l2.p1.x { + grad = if v2.x > 0.0 { SURROGATE } else { -SURROGATE }; + } + if param == self.l2.p1.y { + grad = if v2.y > 0.0 { SURROGATE } else { -SURROGATE }; + } + if param == self.l2.p2.x { + grad = if v2.x > 0.0 { -SURROGATE } else { SURROGATE }; + } + if param == self.l2.p2.y { + grad = if v2.y > 0.0 { -SURROGATE } else { SURROGATE }; + } + } + } + + (err, grad) + } +} + +impl Constraint for EqualLineLength { + fn params(&self) -> Vec { + vec![self.l1.p1.x, self.l1.p1.y, self.l1.p2.x, self.l1.p2.y, self.l2.p1.x, self.l2.p1.y, self.l2.p2.x, self.l2.p2.y] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + self.error_grad(store, None).0 + } + + fn grad_value(&self, store: &ParamStore, param: ParamId) -> f64 { + self.error_grad(store, Some(param)).1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::test_support::assert_grad_matches_finite_difference; + + fn line(store: &mut ParamStore, x1: f64, y1: f64, x2: f64, y2: f64) -> Line { + Line { + p1: Point::new(store.add(x1, false), store.add(y1, false)), + p2: Point::new(store.add(x2, false), store.add(y2, false)), + } + } + + fn point(store: &mut ParamStore, x: f64, y: f64) -> Point { + Point::new(store.add(x, false), store.add(y, false)) + } + + #[test] + fn equal_is_zero_when_params_match_and_gradient_checks_out_at_ratio_one() { + let mut store = ParamStore::new(); + let a = store.add(3.0, false); + let b = store.add(3.0, false); + let c = Equal::new(a, b, 1.0); + assert!(c.error_value(&store).abs() < 1e-12); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn difference_matches_hand_computation() { + let mut store = ParamStore::new(); + let a = store.add(5.0, false); + let b = store.add(8.0, false); + let d = store.add(3.0, false); + let c = Difference::new(a, b, d); + assert!(c.error_value(&store).abs() < 1e-12); // 8 - 5 == 3 + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn center_of_gravity_matches_hand_computation() { + let mut store = ParamStore::new(); + let p1 = store.add(0.0, false); + let p2 = store.add(10.0, false); + let center = store.add(5.0, false); // midpoint == center of gravity at equal weights + let c = CenterOfGravity::new(center, vec![p1, p2], vec![0.5, 0.5]); + assert!(c.error_value(&store).abs() < 1e-12); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn weighted_linear_combination_matches_hand_computation() { + let mut store = ParamStore::new(); + let pole0 = store.add(0.0, false); + let pole1 = store.add(10.0, false); + let w0 = store.add(1.0, false); + let w1 = store.add(1.0, false); + // factors [0.5, 0.5] both weight 1 -> point should equal (0*1*0.5 + 10*1*0.5) / (1*0.5+1*0.5) = 5 + let point_param = store.add(5.0, false); + let c = WeightedLinearCombination::new(point_param, vec![pole0, pole1], vec![w0, w1], vec![0.5, 0.5]); + assert!(c.error_value(&store).abs() < 1e-12); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn p2p_distance_matches_pythagoras() { + let mut store = ParamStore::new(); + let p1 = point(&mut store, 0.0, 0.0); + let p2 = point(&mut store, 3.0, 4.0); + let d = store.add(5.0, false); + let c = P2PDistance::new(p1, p2, d); + assert!(c.error_value(&store).abs() < 1e-12); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn p2l_distance_matches_hand_computation() { + let mut store = ParamStore::new(); + let p = point(&mut store, 0.0, 5.0); + let l = line(&mut store, -10.0, 0.0, 10.0, 0.0); // the x axis + let d = store.add(5.0, false); + let c = P2LDistance::new(p, l, d, true); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn point_on_line_is_zero_for_a_collinear_point() { + let mut store = ParamStore::new(); + let p = point(&mut store, 5.0, 5.0); + let l = line(&mut store, 0.0, 0.0, 10.0, 10.0); + let c = PointOnLine::new(p, l); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn point_on_perp_bisector_is_zero_for_an_equidistant_point() { + let mut store = ParamStore::new(); + let p = point(&mut store, 5.0, 100.0); // equidistant from (0,0) and (10,0) + let l = line(&mut store, 0.0, 0.0, 10.0, 0.0); + let c = PointOnPerpBisector::new(p, l); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn parallel_is_zero_for_parallel_lines_and_gradient_checks_out() { + let mut store = ParamStore::new(); + let l1 = line(&mut store, 0.0, 0.0, 10.0, 5.0); + let l2 = line(&mut store, 1.0, 1.0, 11.0, 6.0); + let c = Parallel::new(&store, l1, l2); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn perpendicular_is_zero_for_perpendicular_lines_and_gradient_checks_out() { + let mut store = ParamStore::new(); + let l1 = line(&mut store, 0.0, 0.0, 10.0, 0.0); + let l2 = line(&mut store, 0.0, 0.0, 0.0, 10.0); + let c = Perpendicular::new(&store, l1, l2); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn midpoint_on_line_is_zero_when_satisfied() { + let mut store = ParamStore::new(); + let l1 = line(&mut store, 0.0, 10.0, 10.0, 10.0); // midpoint (5, 10) + let l2 = line(&mut store, 5.0, 0.0, 5.0, 20.0); // vertical line through x=5 + let c = MidpointOnLine::new(l1, l2); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } + + #[test] + fn equal_line_length_is_zero_for_equal_length_lines() { + let mut store = ParamStore::new(); + let l1 = line(&mut store, 0.0, 0.0, 3.0, 4.0); // length 5 + let l2 = line(&mut store, 0.0, 0.0, 5.0, 0.0); // length 5 + let c = EqualLineLength::new(l1, l2); + assert!(c.error_value(&store).abs() < 1e-9); + assert_grad_matches_finite_difference(&c, &mut store); + } +} diff --git a/crates/ocs_gcs/src/diagnosis.rs b/crates/ocs_gcs/src/diagnosis.rs new file mode 100644 index 000000000..db806a462 --- /dev/null +++ b/crates/ocs_gcs/src/diagnosis.rs @@ -0,0 +1,265 @@ +//! Degrees-of-freedom and redundant-constraint diagnosis. +//! +//! Ported in spirit, not line-by-line, from `System::diagnose` and +//! `identifyConflictingRedundantConstraints` (`GCS.cpp`). The C++ version is +//! substantially more machinery than the rest of this port: it runs two QR +//! decompositions in parallel threads, switches between dense and sparse QR +//! by system size, tracks per-constraint "tags" (so a caller can mark +//! certain constraints high-priority or exempt from the conflict report), +//! and distinguishes *which* rows of a rank-deficient Jacobian are +//! genuinely redundant (safe to drop) via Eigen's `FullPivHouseholderQR` +//! column tracking. +//! +//! This gets to the same two numbers a caller actually needs — the +//! remaining degrees of freedom, and a set of constraints that could be +//! removed without changing what the rest of the system can satisfy — via +//! plain rank-revealing SVD (already used the same way in `qp_eq.rs`) and a +//! straightforward incremental-rank scan, at the cost of two simplifications +//! worth knowing about before relying on this for anything but a UI hint: +//! +//! - **No tag system.** Every constraint in the subsystem is considered; +//! there's no way yet to exempt a particular constraint from the report. +//! - **Redundant vs. conflicting is a separate, opt-in step.** [`diagnose`] +//! itself only identifies constraint rows that are *linearly dependent* on +//! the others (the Jacobian doesn't have full row rank) — safe to drop +//! without changing the solvable configuration space, but not yet telling +//! apart a harmless duplicate from a genuinely *conflicting* constraint +//! (dependent AND infeasible together, e.g. two different fixed distances +//! between the same two points). [`classify_redundant`] answers that, +//! for exactly the rows `diagnose` flagged, by attempting a solve on the +//! reduced system and checking whether the removed row's own error can +//! still reach zero there — kept separate from `diagnose` because it costs +//! one extra solve per redundant row, which `diagnose`'s own "cheap enough +//! for every edit" contract doesn't afford. + +use std::rc::Rc; + +use nalgebra::DMatrix; + +use crate::constraints::Constraint; +use crate::subsystem::SubSystem; +use crate::util::ParamStore; + +pub struct Diagnosis { + /// Remaining degrees of freedom: free parameters minus the Jacobian's + /// rank. Zero means fully constrained. + pub dof: usize, + /// Row indices into [`SubSystem::constraints`] that are linearly + /// dependent on the rest — candidates to drop when over-constrained. + pub redundant: Vec, +} + +impl Diagnosis { + /// The actual redundant [`Constraint`]s, resolved from `redundant` + /// against `sub`'s own constraint list. + pub fn redundant_constraints<'a>(&self, sub: &'a SubSystem) -> Vec<&'a Rc> { + self.redundant.iter().map(|&i| &sub.constraints()[i]).collect() + } +} + +/// Diagnoses `sub` at the store's current parameter values. Cheap enough to +/// call after every edit for a live DOF readout — the UX research behind +/// this port's plan flagged that as something every incumbent CAD tool +/// under-serves. +pub fn diagnose(sub: &SubSystem, store: &ParamStore) -> Diagnosis { + let jacobi = sub.calc_jacobi(store); + let psize = sub.p_size(); + let csize = sub.c_size(); + + let full_rank = matrix_rank(&jacobi); + let dof = psize.saturating_sub(full_rank); + + let mut kept_rows: Vec = Vec::with_capacity(csize); + let mut redundant: Vec = Vec::new(); + let mut current_rank = 0usize; + + for i in 0..csize { + let mut trial = kept_rows.clone(); + trial.push(i); + let trial_rank = matrix_rank(&row_submatrix(&jacobi, &trial)); + if trial_rank > current_rank { + kept_rows.push(i); + current_rank = trial_rank; + } else { + redundant.push(i); + } + } + + Diagnosis { dof, redundant } +} + +/// Whether a redundant (linearly-dependent) constraint is safe to drop or +/// actively disagrees with the rest of the subsystem — the distinction +/// [`diagnose`] itself deliberately doesn't make (see the module doc +/// comment) and [`classify_redundant`] exists to add on top of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RedundancyKind { + /// Removing this constraint doesn't change what the rest can satisfy — + /// its own condition is already implied by the others' solution (e.g. + /// the same Equal constraint added twice). + Redundant, + /// Removing this constraint lets the rest solve to a configuration that + /// does *not* satisfy this constraint's own error function — it + /// disagrees with the rest, not just duplicates one of them (e.g. two + /// different fixed distances between the same two points). + Conflicting, +} + +/// Below this, a redundant constraint's own error at the reduced system's +/// solved position counts as "satisfied" (genuinely redundant) rather than +/// "violated" (conflicting). Matches the tolerance `solvers` use to call a +/// solve `Success` at sketch scale. +const CONFLICT_TOLERANCE: f64 = 1e-6; + +/// For each `redundant` row `diagnose` flagged on `sub` (linearly dependent +/// on the rest, so dropping it doesn't reduce DOF), determines whether it's +/// genuinely [`RedundancyKind::Redundant`] or actually +/// [`RedundancyKind::Conflicting`] with the rest — the distinction the +/// module doc comment describes: remove it, solve what remains (from a +/// clone of `store`, so the caller's real parameter values are untouched), +/// and check whether the *removed* constraint's own error function reaches +/// ~0 at that solved position. If the rest of the subsystem naturally +/// settles somewhere that already satisfies it, it was redundant; if the +/// rest settles somewhere that still violates it, the two disagree. +/// +/// Deliberately a separate, opt-in call from [`diagnose`] rather than folded +/// into it: this does one extra solve per redundant row, which [`diagnose`]'s +/// own "cheap enough for every edit" contract doesn't afford — but the +/// common case (no redundant rows at all) never pays for it, since a caller +/// only reaches for this once `diagnose` has already reported something to +/// classify (e.g. the plan's SketchXpert-style resolver UI). +pub fn classify_redundant(sub: &SubSystem, store: &ParamStore, redundant: &[usize]) -> Vec<(usize, RedundancyKind)> { + redundant + .iter() + .map(|&index| { + let reduced: Vec> = sub + .constraints() + .iter() + .enumerate() + .filter_map(|(i, c)| (i != index).then(|| c.clone())) + .collect(); + let reduced_sub = SubSystem::new(reduced, sub.plist()); + let mut reduced_store = store.clone(); + let _ = crate::solvers::dogleg::solve_dl(&reduced_sub, &mut reduced_store); + let residual = sub.constraints()[index].error(&reduced_store).abs(); + let kind = if residual < CONFLICT_TOLERANCE { RedundancyKind::Redundant } else { RedundancyKind::Conflicting }; + (index, kind) + }) + .collect() +} + +fn row_submatrix(m: &DMatrix, rows: &[usize]) -> DMatrix { + let owned_rows: Vec<_> = rows.iter().map(|&r| m.row(r).clone_owned()).collect(); + DMatrix::from_rows(&owned_rows) +} + +fn matrix_rank(m: &DMatrix) -> usize { + if m.nrows() == 0 || m.ncols() == 0 { + return 0; + } + let singular_values = m.clone().singular_values(); + let tol = singular_values.max() * f64::EPSILON * (m.nrows().max(m.ncols()) as f64); + singular_values.iter().filter(|&&s| s > tol).count() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::point_line::{Difference, Equal}; + use crate::geo::Point; + use crate::util::ParamStore; + + #[test] + fn a_fully_constrained_two_point_distance_has_zero_dof() { + let mut store = ParamStore::new(); + let p1 = Point::new(store.add(0.0, true), store.add(0.0, true)); + let p2 = Point::new(store.add(3.0, false), store.add(4.0, false)); + let distance = store.add(5.0, true); + let c: Rc = Rc::new(crate::constraints::point_line::P2PDistance::new(p1, p2, distance)); + // Only one constraint on two free params (p2.x, p2.y): one DOF left + // (free to slide around the circle), matching a real sketch's math, + // not "fully constrained" -- this test is really about the *shape* + // of the result, exercised precisely by the redundancy tests below. + let sub = SubSystem::new(vec![c], &[p2.x, p2.y]); + let diag = diagnose(&sub, &store); + assert_eq!(diag.dof, 1); + assert!(diag.redundant.is_empty()); + } + + #[test] + fn a_duplicated_constraint_is_flagged_redundant() { + let mut store = ParamStore::new(); + let a = store.add(3.0, false); + let b = store.add(3.0, false); + + let c1: Rc = Rc::new(Equal::new(a, b, 1.0)); + let c2: Rc = Rc::new(Equal::new(a, b, 1.0)); // identical constraint again + let sub = SubSystem::new(vec![c1, c2], &[a, b]); + + let diag = diagnose(&sub, &store); + assert_eq!(diag.redundant.len(), 1, "one of the two identical constraints should be redundant"); + assert_eq!(diag.dof, 1); // Equal removes exactly one DOF regardless of duplication + } + + #[test] + fn independent_constraints_are_never_flagged_redundant() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, false); + let d1 = store.add(0.0, false); + let d2 = store.add(0.0, false); + + // Two different constraints touching disjoint pairs -- both needed. + let c1: Rc = Rc::new(Difference::new(a, b, d1)); + let c2: Rc = Rc::new(Difference::new(a, b, d2)); + // Same params, but only redundant if their gradients are parallel -- + // Difference's gradient w.r.t. (a,b) is always (-1,1) regardless of + // the target, so these ARE linearly dependent rows; use this to + // confirm the flagged set resolves back to real constraints. + let sub = SubSystem::new(vec![c1, c2], &[a, b]); + let diag = diagnose(&sub, &store); + assert_eq!(diag.redundant.len(), 1); + let resolved = diag.redundant_constraints(&sub); + assert_eq!(resolved.len(), 1); + } + + #[test] + fn a_duplicated_constraint_classifies_as_genuinely_redundant() { + let mut store = ParamStore::new(); + let a = store.add(3.0, false); + let b = store.add(3.0, false); + let c1: Rc = Rc::new(Equal::new(a, b, 1.0)); + let c2: Rc = Rc::new(Equal::new(a, b, 1.0)); // identical constraint again + let sub = SubSystem::new(vec![c1, c2], &[a, b]); + + let diag = diagnose(&sub, &store); + assert_eq!(diag.redundant.len(), 1); + let classified = classify_redundant(&sub, &store, &diag.redundant); + assert_eq!( + classified, + vec![(diag.redundant[0], RedundancyKind::Redundant)], + "the duplicate should classify as redundant, not conflicting" + ); + } + + #[test] + fn two_different_fixed_targets_on_the_same_pair_classify_as_conflicting() { + let mut store = ParamStore::new(); + let a = store.add(0.0, false); + let b = store.add(1.0, false); + let d1 = store.add(0.0, true); // wants b - a == 0 + let d2 = store.add(5.0, true); // wants b - a == 5 -- can't both hold + let c1: Rc = Rc::new(Difference::new(a, b, d1)); + let c2: Rc = Rc::new(Difference::new(a, b, d2)); + let sub = SubSystem::new(vec![c1, c2], &[a, b]); + + let diag = diagnose(&sub, &store); + assert_eq!(diag.redundant.len(), 1, "the two Difference rows are linearly dependent regardless of target"); + let classified = classify_redundant(&sub, &store, &diag.redundant); + assert_eq!( + classified, + vec![(diag.redundant[0], RedundancyKind::Conflicting)], + "two incompatible fixed targets on the same pair must classify as conflicting, not redundant" + ); + } +} diff --git a/crates/ocs_gcs/src/geo.rs b/crates/ocs_gcs/src/geo.rs new file mode 100644 index 000000000..698a817d9 --- /dev/null +++ b/crates/ocs_gcs/src/geo.rs @@ -0,0 +1,1169 @@ +//! Curve geometry: points, the value-with-derivative type, and the curves +//! the solver operates on (line, circle, arc — conics and B-splines are +//! deferred to later port stages). +//! +//! Ported from planegcs's `Geo.h`/`Geo.cpp`. The one structural change from +//! the C++: planegcs's `PushOwnParams`/`ReconstructOnNewPvec` exist only to +//! support its raw-pointer parameter-redirection trick (see `util.rs`); since +//! curves here hold [`ParamId`]s rather than pointers, no such +//! reconstruction is ever needed and those methods have no equivalent. + +use crate::util::{ParamId, ParamStore}; + +/// A 2D point addressed by parameter id, not by value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Point { + pub x: ParamId, + pub y: ParamId, +} + +impl Point { + pub fn new(x: ParamId, y: ParamId) -> Self { + Self { x, y } + } +} + +/// A 2D vector paired with its derivative w.r.t. whichever parameter the +/// caller is currently differentiating for ("derivparam"). Reading a +/// [`Point`] through [`DeriVector2::from_point`] sets `dx`/`dy` to 1 where +/// the point's own x/y parameter *is* derivparam, 0 otherwise — the seed +/// values automatic-differentiation-by-hand starts from. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DeriVector2 { + pub x: f64, + pub dx: f64, + pub y: f64, + pub dy: f64, +} + +impl DeriVector2 { + pub fn new(x: f64, y: f64) -> Self { + Self { x, dx: 0.0, y, dy: 0.0 } + } + + pub fn with_deriv(x: f64, y: f64, dx: f64, dy: f64) -> Self { + Self { x, dx, y, dy } + } + + /// Reads a point's value from `store`, seeding the derivative as 1 for + /// whichever of the point's coordinates equals `derivparam`. + pub fn from_point(store: &ParamStore, p: Point, derivparam: Option) -> Self { + Self { + x: store.get(p.x), + dx: if derivparam == Some(p.x) { 1.0 } else { 0.0 }, + y: store.get(p.y), + dy: if derivparam == Some(p.y) { 1.0 } else { 0.0 }, + } + } + + pub fn length(&self) -> f64 { + (self.x * self.x + self.y * self.y).sqrt() + } + + /// Returns `(length, d(length))`. + pub fn length_deriv(&self) -> (f64, f64) { + let l = self.length(); + if l == 0.0 { + return (l, 1.0); + } + (l, (self.x * self.dx + self.y * self.dy) / l) + } + + /// Unlike the other operations here, this returns a new vector rather + /// than mutating `self` — matching planegcs's own `getNormalized`. + /// Returns the zero vector (derivative preserved) if `self` is zero. + pub fn normalized(&self) -> Self { + let l = self.length(); + if l == 0.0 { + return Self { x: 0.0, dx: self.dx, y: 0.0, dy: self.dy }; + } + let mut rtn = Self { x: self.x / l, y: self.y / l, dx: self.dx / l, dy: self.dy / l }; + let dsc = rtn.dx * rtn.x + rtn.dy * rtn.y; + rtn.dx -= dsc * rtn.x; + rtn.dy -= dsc * rtn.y; + rtn + } + + /// Returns `(scalar product, d(scalar product))`. + pub fn scalar_prod(&self, v2: &Self) -> (f64, f64) { + let dprd = self.dx * v2.x + self.x * v2.dx + self.dy * v2.y + self.y * v2.dy; + (self.x * v2.x + self.y * v2.y, dprd) + } + + /// Returns `(z of cross product, d(z of cross product))`, treating both + /// vectors as 3D with a zero z component. + pub fn cross_prod_z(&self, v2: &Self) -> (f64, f64) { + let dprd = self.dx * v2.y + self.x * v2.dy - self.dy * v2.x - self.y * v2.dx; + (self.x * v2.y - self.y * v2.x, dprd) + } + + pub fn sum(&self, v2: &Self) -> Self { + Self { x: self.x + v2.x, y: self.y + v2.y, dx: self.dx + v2.dx, dy: self.dy + v2.dy } + } + + pub fn subtr(&self, v2: &Self) -> Self { + Self { x: self.x - v2.x, y: self.y - v2.y, dx: self.dx - v2.dx, dy: self.dy - v2.dy } + } + + pub fn mult(&self, val: f64) -> Self { + Self { x: self.x * val, y: self.y * val, dx: self.dx * val, dy: self.dy * val } + } + + /// Multiplies by a scalar that itself has a derivative (`val`, `dval`). + pub fn mult_d(&self, val: f64, dval: f64) -> Self { + Self { + x: self.x * val, + y: self.y * val, + dx: self.dx * val + self.x * dval, + dy: self.dy * val + self.y * dval, + } + } + + /// Divides by a scalar that itself has a derivative (`val`, `dval`). + pub fn div_d(&self, val: f64, dval: f64) -> Self { + Self { + x: self.x / val, + y: self.y / val, + dx: self.dx / val - self.x * dval / (val * val), + dy: self.dy / val - self.y * dval / (val * val), + } + } + + pub fn rotate90ccw(&self) -> Self { + Self { x: -self.y, y: self.x, dx: -self.dy, dy: self.dx } + } + + pub fn rotate90cw(&self) -> Self { + Self { x: self.y, y: -self.x, dx: self.dy, dy: -self.dx } + } + + /// Linear combination `self * m1 + v2 * m2`. + pub fn lin_combi(&self, m1: f64, v2: &Self, m2: f64) -> Self { + Self { + x: self.x * m1 + v2.x * m2, + y: self.y * m1 + v2.y * m2, + dx: self.dx * m1 + v2.dx * m2, + dy: self.dy * m1 + v2.dy * m2, + } + } +} + +/// A curve a constraint can evaluate: a point on it (and its normal) for a +/// given parameter `u`, differentiated w.r.t. `derivparam`. +/// +/// Normals point to the left when walking the curve from start to end; +/// circles/arcs are walked counterclockwise, so their normal points inward. +/// +/// Object-safe by design: constraints that work with an arbitrary curve type +/// picked at construction time (`CurveValue`, the `AngleViaPoint` family, +/// `Snell` — see [`crate::constraints::curve_generic`]) hold it as a +/// `Rc`, mirroring planegcs's own `Curve*` fields in those +/// constraint classes. +pub trait Curve { + /// Normal at a point `p` that need not lie exactly on the curve (e.g. a + /// not-yet-solved sketch point being pulled onto it). Default impl + /// delegates to [`calculate_normal_from_value`](Self::calculate_normal_from_value) + /// after sampling `p` from the store — what differs per curve type is + /// only the formula once you *have* the point's value, not how the + /// point's value is obtained. + fn calculate_normal(&self, store: &ParamStore, p: Point, derivparam: Option) -> DeriVector2 { + self.calculate_normal_from_value(store, DeriVector2::from_point(store, p, derivparam), derivparam) + } + + /// The per-curve-type formula behind [`calculate_normal`](Self::calculate_normal) + /// and [`calculate_normal_at_param`](Self::calculate_normal_at_param) alike, taking + /// the point already resolved to a value (with derivative) rather than a + /// stored [`Point`] — since [`calculate_normal_at_param`](Self::calculate_normal_at_param) + /// has no `Point` to give it, only a value computed from `u`. + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2; + + /// Normal at the curve's own point-at-parameter `u`, rather than at a + /// separately stored [`Point`]. Mirrors planegcs's second `CalculateNormal` + /// overload (`Geo.h`)'s *default* implementation (the one every curve + /// here uses — only `BSpline` gives it a real override, with a full + /// de Boor derivation, which this port doesn't implement): the point on + /// the curve is sampled with `derivparam = None`, always, even when the + /// call came in with a real `derivparam` — so the point's own *position* + /// never contributes to the returned derivative, only whatever the + /// normal formula picks up directly and independently from the curve's + /// own shape parameters. + /// + /// This is a real, documented limitation in upstream planegcs, not a + /// simplification of this port, and it is broader than just "`u` is + /// exempt from differentiation": for any curve whose normal formula + /// itself depends on the point's position (`Circle`/`Arc`, the conics — + /// their [`calculate_normal_from_value`](Self::calculate_normal_from_value) + /// reads `p`), differentiating w.r.t. *any* of that curve's own shape + /// parameters is silently incomplete here too, because the true + /// position shift such a parameter causes never reaches the frozen `p`. + /// `Line`'s normal doesn't depend on position at all, so it alone is + /// unaffected — every parameter's derivative through it is exact. + fn calculate_normal_at_param(&self, store: &ParamStore, u: f64, derivparam: Option) -> DeriVector2 { + let p = self.value(store, u, 0.0, None); + self.calculate_normal_from_value(store, p, derivparam) + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2; + + /// This curve's own parameters (its defining points/radii/angles) — + /// planegcs's `PushOwnParams`. Needed only by constraints that hold a + /// curve generically (as `Rc`) and so can't just list a + /// concrete struct's fields by hand the way e.g. `Parallel` + /// (`point_line.rs`) does for its own `Line`s. + fn own_params(&self) -> Vec; +} + +#[derive(Debug, Clone, Copy)] +pub struct Line { + pub p1: Point, + pub p2: Point, +} + +impl Curve for Line { + fn calculate_normal_from_value(&self, store: &ParamStore, _p: DeriVector2, derivparam: Option) -> DeriVector2 { + let p1v = DeriVector2::from_point(store, self.p1, derivparam); + let p2v = DeriVector2::from_point(store, self.p2, derivparam); + p2v.subtr(&p1v).rotate90ccw() + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + let p1v = DeriVector2::from_point(store, self.p1, derivparam); + let p2v = DeriVector2::from_point(store, self.p2, derivparam); + let line_vec = p2v.subtr(&p1v); + p1v.sum(&line_vec.mult_d(u, du)) + } + + fn own_params(&self) -> Vec { + vec![self.p1.x, self.p1.y, self.p2.x, self.p2.y] + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Circle { + pub center: Point, + pub rad: ParamId, +} + +impl Curve for Circle { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + let cv = DeriVector2::from_point(store, self.center, derivparam); + cv.subtr(&p) + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + let cv = DeriVector2::from_point(store, self.center, derivparam); + let r = store.get(self.rad); + let dr = if derivparam == Some(self.rad) { 1.0 } else { 0.0 }; + let ex = DeriVector2::with_deriv(r, 0.0, dr, 0.0); + let ey = ex.rotate90ccw(); + let (si, co) = u.sin_cos(); + let dsi = du * co; + let dco = du * (-si); + cv.sum(&ex.mult_d(co, dco).sum(&ey.mult_d(si, dsi))) + } + + fn own_params(&self) -> Vec { + vec![self.center.x, self.center.y, self.rad] + } +} + +/// Arc inherits Circle's `Value`/`CalculateNormal` unchanged in planegcs (it +/// only adds start/end/angle bookkeeping used by the higher-level "arc +/// rules" constraint, not by the curve evaluation itself) — so here it just +/// wraps a `Circle` and delegates. +#[derive(Debug, Clone, Copy)] +pub struct Arc { + pub circle: Circle, + pub start: Point, + pub end: Point, + pub start_angle: ParamId, + pub end_angle: ParamId, +} + +impl Curve for Arc { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + self.circle.calculate_normal_from_value(store, p, derivparam) + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + self.circle.value(store, u, du, derivparam) + } + + fn own_params(&self) -> Vec { + let mut params = self.circle.own_params(); + params.extend([self.start.x, self.start.y, self.end.x, self.end.y, self.start_angle, self.end_angle]); + params + } +} + +/// An ellipse given by its centre, one focus, and its minor radius (the +/// major radius/axis follow from those three, via [`Ellipse::rad_maj`]). +#[derive(Debug, Clone, Copy)] +pub struct Ellipse { + pub center: Point, + pub focus1: Point, + pub radmin: ParamId, +} + +impl Ellipse { + /// Major radius and its derivative, given already-sampled `center`/`f1` + /// vectors and the minor radius `b`/`db` — exposed (as in the C++) so + /// constraint code can reuse `DeriVector2`s it already built rather than + /// resampling from the store. + pub fn rad_maj(center: &DeriVector2, f1: &DeriVector2, b: f64, db: f64) -> (f64, f64) { + let cf = f1.subtr(center); + let (cf_len, dcf) = cf.length_deriv(); + // A "vector" (b, cf_len) whose own length formula happens to equal + // the major-radius formula (a² = b² + c²) — reuses `length_deriv` + // rather than repeating the same sqrt-of-sum-of-squares by hand. + let hack = DeriVector2::with_deriv(b, cf_len, db, dcf); + hack.length_deriv() + } + + /// [`rad_maj`](Self::rad_maj), sampling `center`/`focus1`/`radmin` from + /// `store` itself — mirrors the C++'s other `getRadMaj(derivparam, ...)` + /// overload. + pub fn rad_maj_at(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let c = DeriVector2::from_point(store, self.center, derivparam); + let f1 = DeriVector2::from_point(store, self.focus1, derivparam); + let b = store.get(self.radmin); + let db = if derivparam == Some(self.radmin) { 1.0 } else { 0.0 }; + Self::rad_maj(&c, &f1, b, db) + } +} + +impl Curve for Ellipse { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + let cv = DeriVector2::from_point(store, self.center, derivparam); + let f1v = DeriVector2::from_point(store, self.focus1, derivparam); + + let f2v = cv.lin_combi(2.0, &f1v, -1.0); + let pf1 = f1v.subtr(&p); + let pf2 = f2v.subtr(&p); + pf1.normalized().sum(&pf2.normalized()) + } + + fn own_params(&self) -> Vec { + vec![self.center.x, self.center.y, self.focus1.x, self.focus1.y, self.radmin] + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + let c = DeriVector2::from_point(store, self.center, derivparam); + let f1 = DeriVector2::from_point(store, self.focus1, derivparam); + + let emaj = f1.subtr(&c).normalized(); + let emin = emaj.rotate90ccw(); + let b = store.get(self.radmin); + let db = if derivparam == Some(self.radmin) { 1.0 } else { 0.0 }; + let (a, da) = Self::rad_maj(&c, &f1, b, db); + let a_vec = emaj.mult_d(a, da); + let b_vec = emin.mult_d(b, db); + + let (si, co) = u.sin_cos(); + let dco = -si * du; + let dsi = co * du; + + a_vec.mult_d(co, dco).sum(&b_vec.mult_d(si, dsi)).sum(&c) + } +} + +/// Inherits `Ellipse`'s `Value`/`CalculateNormal` unchanged, same as `Arc` +/// does for `Circle`. +#[derive(Debug, Clone, Copy)] +pub struct ArcOfEllipse { + pub ellipse: Ellipse, + pub start: Point, + pub end: Point, + pub start_angle: ParamId, + pub end_angle: ParamId, +} + +impl Curve for ArcOfEllipse { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + self.ellipse.calculate_normal_from_value(store, p, derivparam) + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + self.ellipse.value(store, u, du, derivparam) + } + + fn own_params(&self) -> Vec { + let mut params = self.ellipse.own_params(); + params.extend([self.start.x, self.start.y, self.end.x, self.end.y, self.start_angle, self.end_angle]); + params + } +} + +/// A hyperbola given the same way as [`Ellipse`] (centre, one focus, minor +/// radius); `rad_maj` uses `a² = c² - b²` instead of `a² = b² + c²`. +#[derive(Debug, Clone, Copy)] +pub struct Hyperbola { + pub center: Point, + pub focus1: Point, + pub radmin: ParamId, +} + +impl Hyperbola { + pub fn rad_maj(center: &DeriVector2, f1: &DeriVector2, b: f64, db: f64) -> (f64, f64) { + let cf = f1.subtr(center); + let (cf_len, dcf) = cf.length_deriv(); + let a = (cf_len * cf_len - b * b).sqrt(); + let da = (dcf * cf_len - db * b) / a; + (a, da) + } + + pub fn rad_maj_at(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + let c = DeriVector2::from_point(store, self.center, derivparam); + let f1 = DeriVector2::from_point(store, self.focus1, derivparam); + let b = store.get(self.radmin); + let db = if derivparam == Some(self.radmin) { 1.0 } else { 0.0 }; + Self::rad_maj(&c, &f1, b, db) + } +} + +/// Either kind of "major radius conic" — the closed set planegcs's abstract +/// `MajorRadiusConic` base class stands in for, needed so a constraint like +/// [`EqualMajorAxesConic`](crate::constraints::conic::EqualMajorAxesConic) +/// can compare an ellipse against a hyperbola without knowing which is +/// which. +#[derive(Debug, Clone, Copy)] +pub enum Conic { + Ellipse(Ellipse), + Hyperbola(Hyperbola), +} + +impl Conic { + pub fn rad_maj_at(&self, store: &ParamStore, derivparam: Option) -> (f64, f64) { + match self { + Conic::Ellipse(e) => e.rad_maj_at(store, derivparam), + Conic::Hyperbola(h) => h.rad_maj_at(store, derivparam), + } + } + + pub fn own_params(&self) -> Vec { + match self { + Conic::Ellipse(e) => vec![e.center.x, e.center.y, e.focus1.x, e.focus1.y, e.radmin], + Conic::Hyperbola(h) => vec![h.center.x, h.center.y, h.focus1.x, h.focus1.y, h.radmin], + } + } +} + +impl Curve for Hyperbola { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + let cv = DeriVector2::from_point(store, self.center, derivparam); + let f1v = DeriVector2::from_point(store, self.focus1, derivparam); + + let f2v = cv.lin_combi(2.0, &f1v, -1.0); + // Differs from the ellipse's normal by inverting this vector, as in + // the C++. + let pf1 = f1v.subtr(&p).mult(-1.0); + let pf2 = f2v.subtr(&p); + pf1.normalized().sum(&pf2.normalized()) + } + + fn own_params(&self) -> Vec { + vec![self.center.x, self.center.y, self.focus1.x, self.focus1.y, self.radmin] + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + let c = DeriVector2::from_point(store, self.center, derivparam); + let f1 = DeriVector2::from_point(store, self.focus1, derivparam); + + let emaj = f1.subtr(&c).normalized(); + let emin = emaj.rotate90ccw(); + let b = store.get(self.radmin); + let db = if derivparam == Some(self.radmin) { 1.0 } else { 0.0 }; + let (a, da) = Self::rad_maj(&c, &f1, b, db); + let a_vec = emaj.mult_d(a, da); + let b_vec = emin.mult_d(b, db); + + let co = u.cosh(); + let si = u.sinh(); + let dco = si * du; + let dsi = co * du; + + a_vec.mult_d(co, dco).sum(&b_vec.mult_d(si, dsi)).sum(&c) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ArcOfHyperbola { + pub hyperbola: Hyperbola, + pub start: Point, + pub end: Point, + pub start_angle: ParamId, + pub end_angle: ParamId, +} + +impl Curve for ArcOfHyperbola { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + self.hyperbola.calculate_normal_from_value(store, p, derivparam) + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + self.hyperbola.value(store, u, du, derivparam) + } + + fn own_params(&self) -> Vec { + let mut params = self.hyperbola.own_params(); + params.extend([self.start.x, self.start.y, self.end.x, self.end.y, self.start_angle, self.end_angle]); + params + } +} + +/// A parabola given by its vertex and one focus. +#[derive(Debug, Clone, Copy)] +pub struct Parabola { + pub vertex: Point, + pub focus1: Point, +} + +impl Curve for Parabola { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + let cv = DeriVector2::from_point(store, self.vertex, derivparam); + let f1v = DeriVector2::from_point(store, self.focus1, derivparam); + + cv.subtr(&f1v).normalized().subtr(&f1v.subtr(&p).normalized()) + } + + fn own_params(&self) -> Vec { + vec![self.vertex.x, self.vertex.y, self.focus1.x, self.focus1.y] + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + let c = DeriVector2::from_point(store, self.vertex, derivparam); + let f1 = DeriVector2::from_point(store, self.focus1, derivparam); + + let fv = f1.subtr(&c); + let (f, df) = fv.length_deriv(); + + let xdir = fv.normalized(); + let ydir = xdir.rotate90ccw(); + + let dirx = xdir.mult_d(u, du).mult_d(u, du).div_d(4.0 * f, 4.0 * df); + let diry = ydir.mult_d(u, du); + let dir = dirx.sum(&diry); + + c.sum(&dir) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ArcOfParabola { + pub parabola: Parabola, + pub start: Point, + pub end: Point, + pub start_angle: ParamId, + pub end_angle: ParamId, +} + +impl Curve for ArcOfParabola { + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + self.parabola.calculate_normal_from_value(store, p, derivparam) + } + + fn value(&self, store: &ParamStore, u: f64, du: f64, derivparam: Option) -> DeriVector2 { + self.parabola.value(store, u, du, derivparam) + } + + fn own_params(&self) -> Vec { + let mut params = self.parabola.own_params(); + params.extend([self.start.x, self.start.y, self.end.x, self.end.y, self.start_angle, self.end_angle]); + params + } +} + +/// de Boor's algorithm, evaluating one basis-spline linear-combination +/// coefficient (or, iterated over a full pole vector `d`, the resulting +/// curve/derivative value) — planegcs's `BSpline::splineValue`, shared by +/// [`BSpline::get_lin_comb_factor`] and every curve/tangent evaluation +/// below. `flatknots` must already be the *flattened* knot vector (each +/// knot value repeated per its multiplicity), `k` the knot-span index such +/// that `flatknots[k] <= x < flatknots[k + 1]`, and `d` the length-`p + 1` +/// window of (possibly weighted) pole coordinates for that span. +pub(crate) fn spline_value(x: f64, k: usize, p: usize, d: &mut [f64], flatknots: &[f64]) -> f64 { + for r in 1..=p { + for j in (r..=p).rev() { + let alpha = (x - flatknots[j + k - p]) / (flatknots[j + 1 + k - r] - flatknots[j + k - p]); + d[j] = (1.0 - alpha) * d[j - 1] + alpha * d[j]; + } + } + if p < d.len() { d[p] } else { 0.0 } +} + +/// A rational B-spline (NURBS) curve. +/// +/// Two deliberate departures from planegcs's `BSpline`, both because this +/// port targets acadrust's `Spline` entity (`entities/spline.rs` in the +/// `acadrust`/cadcodec crate) as its real consumer, and that entity — like +/// the DXF/DWG formats it round-trips — already stores knots *flattened* +/// (each value repeated per its multiplicity) rather than as planegcs's +/// separate unique-`knots` + `mult` vectors: +/// +/// - `knots` here holds the flattened vector directly (planegcs's own +/// `flattenedknots`, computed on demand via `setupFlattenedKnots()` from +/// the other two) — there is no `mult` to carry, and nothing here needs +/// one: [`find_span`](Self::find_span) locates the active knot span +/// directly via the flattened vector (the standard NURBS-book "FindSpan" +/// search), which is exactly equivalent for a well-formed knot vector +/// without ever reconstructing multiplicities. +/// - `knots` are plain `f64`, not [`ParamId`]s. planegcs's *do* register +/// knots as solver parameters (`PushOwnParams` includes them), but no +/// `Value`/`CalculateNormal` formula in the C++ ever actually +/// differentiates with respect to one — they only ever appear +/// dereferenced as plain numbers — so carrying them as [`ParamId`]s here +/// would add bookkeeping with no corresponding behavior to preserve. +/// +/// Everything else — poles, weights, `degree`, `periodic`, and critically +/// the *values* `Value`/`CalculateNormal` compute — matches planegcs +/// formula-for-formula, including its real limitations (see +/// [`value`](Curve::value) and [`calculate_normal_at_param`](Curve::calculate_normal_at_param) +/// on [`Curve`]). +#[derive(Debug, Clone)] +pub struct BSpline { + pub poles: Vec, + pub weights: Vec, + /// Flattened knot vector — see the struct docs. Length must be + /// `poles.len() + degree + 1`. + pub knots: Vec, + pub start: Point, + pub end: Point, + pub degree: usize, + pub periodic: bool, +} + +impl BSpline { + /// The knot-span index `k` such that `knots[k] <= u < knots[k + 1]` + /// (clamped to the last valid span at the non-periodic end) — what + /// planegcs derives indirectly as `startpole + degree` by walking + /// unique knots and their multiplicities; directly findable here since + /// `knots` is already flattened. + pub(crate) fn find_span(&self, u: f64) -> usize { + let num_poles = self.poles.len(); + if !self.periodic && u >= self.knots[num_poles] { + return num_poles - 1; + } + let mut k = self.degree; + while k + 1 < num_poles && self.knots[k + 1] <= u { + k += 1; + } + k + } + + pub(crate) fn pole_x_at(&self, startpole: usize, i: usize) -> ParamId { + self.poles[(startpole + i) % self.poles.len()].x + } + + pub(crate) fn pole_y_at(&self, startpole: usize, i: usize) -> ParamId { + self.poles[(startpole + i) % self.poles.len()].y + } + + pub(crate) fn weight_at(&self, startpole: usize, i: usize) -> ParamId { + self.weights[(startpole + i) % self.weights.len()] + } + + /// The linear-combination coefficient `B_i(x)` such that + /// `spline(x) = sum(poles[i] * B_i(x))` for a spline of degree `p` — + /// planegcs's `BSpline::getLinCombFactor`. `k` is the knot-span index + /// (see [`find_span`](Self::find_span)) and `i` the (window-relative, + /// i.e. already offset by `startpole`) pole index. + pub fn get_lin_comb_factor(&self, x: f64, k: usize, i: usize, p: usize) -> f64 { + let idx_of_pole = i as isize + p as isize - k as isize; + if idx_of_pole < 0 || idx_of_pole > p as isize { + return 0.0; + } + let mut d = vec![0.0; p + 1]; + d[idx_of_pole as usize] = 1.0; + spline_value(x, k, p, &mut d, &self.knots); + d[p] + } + + /// Homogeneous-coordinate value and its `d/du` at parameter `u`: + /// `(xw, yw, w, dxw/du, dyw/du, dw/du)` — planegcs's `valueHomogenous`. + fn value_homogeneous(&self, store: &ParamStore, u: f64) -> (f64, f64, f64, f64, f64, f64) { + let k = self.find_span(u); + let startpole = k - self.degree; + let numpoints = self.degree + 1; + + let mut d = vec![0.0; numpoints]; + for i in 0..numpoints { + d[i] = store.get(self.pole_x_at(startpole, i)) * store.get(self.weight_at(startpole, i)); + } + let xw = spline_value(u, k, self.degree, &mut d, &self.knots); + for i in 0..numpoints { + d[i] = store.get(self.pole_y_at(startpole, i)) * store.get(self.weight_at(startpole, i)); + } + let yw = spline_value(u, k, self.degree, &mut d, &self.knots); + for i in 0..numpoints { + d[i] = store.get(self.weight_at(startpole, i)); + } + let w = spline_value(u, k, self.degree, &mut d, &self.knots); + + let denom = |i: usize| self.knots[startpole + i + self.degree] - self.knots[startpole + i]; + + let mut sd = vec![0.0; numpoints - 1]; + for i in 1..numpoints { + sd[i - 1] = (store.get(self.pole_x_at(startpole, i)) * store.get(self.weight_at(startpole, i)) + - store.get(self.pole_x_at(startpole, i - 1)) * store.get(self.weight_at(startpole, i - 1))) + / denom(i); + } + let dxw = self.degree as f64 * spline_value(u, k, self.degree - 1, &mut sd, &self.knots); + for i in 1..numpoints { + sd[i - 1] = (store.get(self.pole_y_at(startpole, i)) * store.get(self.weight_at(startpole, i)) + - store.get(self.pole_y_at(startpole, i - 1)) * store.get(self.weight_at(startpole, i - 1))) + / denom(i); + } + let dyw = self.degree as f64 * spline_value(u, k, self.degree - 1, &mut sd, &self.knots); + for i in 1..numpoints { + sd[i - 1] = (store.get(self.weight_at(startpole, i)) - store.get(self.weight_at(startpole, i - 1))) / denom(i); + } + let dw = self.degree as f64 * spline_value(u, k, self.degree - 1, &mut sd, &self.knots); + + (xw, yw, w, dxw, dyw, dw) + } +} + +impl Curve for BSpline { + /// Only defined where planegcs's own default falls back to something + /// other than the zero vector: `p` exactly at the spline's stored + /// `start` or `end` point, where the tangent is unambiguous (from the + /// first two, or last two, poles) even without a general de Boor + /// evaluation. Any other point returns the zero vector, exactly like + /// upstream's `return {};` — general-point normals on a B-spline need + /// [`calculate_normal_at_param`](Curve::calculate_normal_at_param) (`u` + /// known) instead. + fn calculate_normal_from_value(&self, store: &ParamStore, p: DeriVector2, derivparam: Option) -> DeriVector2 { + // planegcs checks `mult[0] > degree && mult[last] > degree` (each + // end knot repeated more than `degree` times — a clamped/open + // spline). Without a separate `mult` vector (see the struct docs), + // the equivalent check on the already-flattened, non-decreasing + // `knots` is: the first `degree + 1` entries (or last `degree + 1`) + // are all equal, i.e. `knots[0] == knots[degree]`. + let n = self.knots.len(); + let is_open_ends = self.knots[0] == self.knots[self.degree] && self.knots[n - 1] == self.knots[n - 1 - self.degree]; + if is_open_ends { + let start = DeriVector2::from_point(store, self.start, None); + if (p.x - start.x).abs() < f64::EPSILON && (p.y - start.y).abs() < f64::EPSILON { + let sp = DeriVector2::from_point(store, self.poles[0], derivparam); + let ep = DeriVector2::from_point(store, self.poles[1], derivparam); + return ep.subtr(&sp).rotate90ccw(); + } + let end = DeriVector2::from_point(store, self.end, None); + if (p.x - end.x).abs() < f64::EPSILON && (p.y - end.y).abs() < f64::EPSILON { + let sp = DeriVector2::from_point(store, self.poles[self.poles.len() - 2], derivparam); + let ep = DeriVector2::from_point(store, self.poles[self.poles.len() - 1], derivparam); + return ep.subtr(&sp).rotate90ccw(); + } + } + DeriVector2::new(0.0, 0.0) + } + + /// Full de Boor tangent/normal-with-derivative at a curve parameter — + /// planegcs's `BSpline::CalculateNormal(const double*, const double*)` + /// override (every other curve here uses the [`Curve`] trait's default + /// for this, but planegcs gives `BSpline` its own full implementation, + /// so this port does too). + fn calculate_normal_at_param(&self, store: &ParamStore, param: f64, derivparam: Option) -> DeriVector2 { + let k = self.find_span(param); + let startpole = k - self.degree; + let numpoints = self.degree + 1; + + let (xsum, ysum, wsum, xslopesum, yslopesum, wslopesum) = self.value_homogeneous(store, param); + let mut result = DeriVector2::new(wsum * xslopesum - wslopesum * xsum, wsum * yslopesum - wslopesum * ysum); + + for i in 0..numpoints { + let (px, py, w) = (self.pole_x_at(startpole, i), self.pole_y_at(startpole, i), self.weight_at(startpole, i)); + if derivparam != Some(px) && derivparam != Some(py) && derivparam != Some(w) { + continue; + } + + let mut d = vec![0.0; numpoints]; + d[i] = 1.0; + let factor = spline_value(param, startpole + self.degree, self.degree, &mut d, &self.knots); + let mut sd = vec![0.0; numpoints - 1]; + let denom = |j: usize| self.knots[startpole + j + self.degree] - self.knots[startpole + j]; + if i > 0 { + sd[i - 1] = 1.0 / denom(i); + } + if i < numpoints - 1 { + sd[i] = -1.0 / denom(i + 1); + } + let slopefactor = spline_value(param, startpole + self.degree, self.degree - 1, &mut sd, &self.knots); + + let wi = store.get(w); + if derivparam == Some(px) { + result.dx = wi * (wsum * slopefactor - wslopesum * factor); + } else if derivparam == Some(py) { + result.dy = wi * (wsum * slopefactor - wslopesum * factor); + } else if derivparam == Some(w) { + let (pxv, pyv) = (store.get(px), store.get(py)); + result.dx = self.degree as f64 * (factor * (xslopesum - wslopesum * pxv) - slopefactor * (xsum - wsum * pxv)); + result.dy = self.degree as f64 * (factor * (yslopesum - wslopesum * pyv) - slopefactor * (ysum - wsum * pyv)); + } + break; + } + + // The curve parameter itself isn't tracked as a `ParamId` in this + // port (see the struct docs), so `derivparam == param` — the C++ + // branch computing second-derivative slope sums for that case — + // has no equivalent here: there is no `ParamId` that could compare + // equal to it in the first place. + result.rotate90ccw() + } + + fn value(&self, store: &ParamStore, u: f64, _du: f64, _derivparam: Option) -> DeriVector2 { + // Matches planegcs's `BSpline::Value` exactly, including its own + // surprising signature: `du` and `derivparam` are both ignored + // (the C++ marks them `/*du*/`, `/*derivparam*/`) — the returned + // `dx`/`dy` is always the raw tangent d(value)/du, regardless of + // what derivative the caller actually asked for. A constraint + // that calls `Curve::value` on a `BSpline` and expects a gradient + // w.r.t. anything other than `u` itself will get a wrong (if + // internally consistent with upstream) answer — a real, inherited + // limitation, not a bug introduced by this port. + let (xw, yw, w, dxw, dyw, dw) = self.value_homogeneous(store, u); + DeriVector2::with_deriv(xw / w, yw / w, (w * dxw - dw * xw) / (w * w), (w * dyw - dw * yw) / (w * w)) + } + + fn own_params(&self) -> Vec { + let mut params = Vec::with_capacity(self.poles.len() * 2 + self.weights.len() + 4); + for pole in &self.poles { + params.push(pole.x); + params.push(pole.y); + } + params.extend(self.weights.iter().copied()); + params.extend([self.start.x, self.start.y, self.end.x, self.end.y]); + params + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPS: f64 = 1e-7; + + fn make_point(store: &mut ParamStore, x: f64, y: f64) -> Point { + Point::new(store.add(x, false), store.add(y, false)) + } + + #[test] + fn line_value_interpolates_between_endpoints() { + let mut store = ParamStore::new(); + let p1 = make_point(&mut store, 0.0, 0.0); + let p2 = make_point(&mut store, 10.0, 0.0); + let line = Line { p1, p2 }; + + let start = line.value(&store, 0.0, 0.0, None); + assert_eq!((start.x, start.y), (0.0, 0.0)); + + let mid = line.value(&store, 0.5, 0.0, None); + assert_eq!((mid.x, mid.y), (5.0, 0.0)); + + let end = line.value(&store, 1.0, 0.0, None); + assert_eq!((end.x, end.y), (10.0, 0.0)); + } + + #[test] + fn line_normal_points_left_of_travel_direction() { + // Walking p1 -> p2 along +x, "left" is +y. + let mut store = ParamStore::new(); + let p1 = make_point(&mut store, 0.0, 0.0); + let p2 = make_point(&mut store, 10.0, 0.0); + let line = Line { p1, p2 }; + + let n = line.calculate_normal(&store, p1, None); + assert!(n.y > 0.0, "expected normal to point toward +y, got {n:?}"); + assert!(n.x.abs() < EPS); + } + + #[test] + fn circle_value_traces_the_circle() { + let mut store = ParamStore::new(); + let center = make_point(&mut store, 1.0, 2.0); + let rad = store.add(3.0, false); + let circle = Circle { center, rad }; + + let at_zero = circle.value(&store, 0.0, 0.0, None); + assert!((at_zero.x - 4.0).abs() < EPS && (at_zero.y - 2.0).abs() < EPS); + + let at_half_pi = circle.value(&store, std::f64::consts::FRAC_PI_2, 0.0, None); + assert!((at_half_pi.x - 1.0).abs() < EPS && (at_half_pi.y - 5.0).abs() < EPS); + } + + #[test] + fn circle_value_derivative_wrt_u_matches_the_tangent() { + // d/du (cx + r cos u, cy + r sin u) = (-r sin u, r cos u); at u=0 that's (0, r). + let mut store = ParamStore::new(); + let center = make_point(&mut store, 0.0, 0.0); + let rad = store.add(5.0, false); + let circle = Circle { center, rad }; + + let v = circle.value(&store, 0.0, 1.0, None); + assert!((v.dx - 0.0).abs() < EPS); + assert!((v.dy - 5.0).abs() < EPS); + } + + #[test] + fn circle_value_derivative_wrt_radius_matches_finite_difference() { + let mut store = ParamStore::new(); + let center = make_point(&mut store, 0.0, 0.0); + let rad = store.add(5.0, false); + let circle = Circle { center, rad }; + let u = 0.7_f64; + + let analytic = circle.value(&store, u, 0.0, Some(rad)); + + let h = 1e-6; + store.set(rad, 5.0 + h); + let plus = circle.value(&store, u, 0.0, None); + store.set(rad, 5.0 - h); + let minus = circle.value(&store, u, 0.0, None); + let numeric_dx = (plus.x - minus.x) / (2.0 * h); + let numeric_dy = (plus.y - minus.y) / (2.0 * h); + + assert!((analytic.dx - numeric_dx).abs() < 1e-6); + assert!((analytic.dy - numeric_dy).abs() < 1e-6); + } + + #[test] + fn circle_normal_points_toward_center() { + let mut store = ParamStore::new(); + let center = make_point(&mut store, 0.0, 0.0); + let rad = store.add(3.0, false); + let circle = Circle { center, rad }; + + let p = make_point(&mut store, 3.0, 0.0); + let n = circle.calculate_normal(&store, p, None); + assert!(n.x < 0.0, "normal at (3,0) should point back toward center, got {n:?}"); + assert!(n.y.abs() < EPS); + } + + #[test] + fn arc_delegates_value_and_normal_to_its_circle() { + let mut store = ParamStore::new(); + let center = make_point(&mut store, 0.0, 0.0); + let rad = store.add(2.0, false); + let start = make_point(&mut store, 2.0, 0.0); + let end = make_point(&mut store, 0.0, 2.0); + let start_angle = store.add(0.0, false); + let end_angle = store.add(std::f64::consts::FRAC_PI_2, false); + let arc = Arc { + circle: Circle { center, rad }, + start, + end, + start_angle, + end_angle, + }; + + let v = arc.value(&store, 0.0, 0.0, None); + assert!((v.x - 2.0).abs() < EPS && v.y.abs() < EPS); + } + + #[test] + fn derivector2_basic_algebra() { + let a = DeriVector2::with_deriv(3.0, 4.0, 1.0, 0.0); + assert!((a.length() - 5.0).abs() < EPS); + + let (len, dlen) = a.length_deriv(); + assert!((len - 5.0).abs() < EPS); + // d(length)/dparam = (x*dx + y*dy)/len = (3*1 + 4*0)/5 = 0.6 + assert!((dlen - 0.6).abs() < EPS); + + let n = a.normalized(); + assert!((n.length() - 1.0).abs() < EPS); + + let rotated = a.rotate90ccw(); + assert!((rotated.x - (-4.0)).abs() < EPS && (rotated.y - 3.0).abs() < EPS); + // rotating twice more (180 total) negates the original vector + let rotated_twice = rotated.rotate90ccw(); + assert!((rotated_twice.x - (-3.0)).abs() < EPS && (rotated_twice.y - (-4.0)).abs() < EPS); + } + + fn make_ellipse(store: &mut ParamStore, cx: f64, cy: f64, c: f64, radmin: f64) -> Ellipse { + // Focus at distance c along +x from the centre -- major axis is +x. + Ellipse { + center: make_point(store, cx, cy), + focus1: make_point(store, cx + c, cy), + radmin: store.add(radmin, false), + } + } + + #[test] + fn ellipse_value_satisfies_the_ellipse_equation_at_several_parameters() { + let mut store = ParamStore::new(); + let ellipse = make_ellipse(&mut store, 0.0, 0.0, 3.0, 4.0); // c=3, b=4 -> a=5 + let (cx, cy) = (0.0, 0.0); + let (a, b) = (5.0, 4.0); + + for &u in &[0.0, 0.7, std::f64::consts::FRAC_PI_2, 2.3, 4.5] { + let v = ellipse.value(&store, u, 0.0, None); + let dx = v.x - cx; + let dy = v.y - cy; + let lhs = (dx / a).powi(2) + (dy / b).powi(2); + assert!((lhs - 1.0).abs() < EPS, "u={u}: off the ellipse, lhs={lhs}"); + } + } + + #[test] + fn ellipse_value_derivative_wrt_radmin_matches_finite_difference() { + let mut store = ParamStore::new(); + let ellipse = make_ellipse(&mut store, 1.0, -2.0, 3.0, 4.0); + let u = 1.1_f64; + let radmin = ellipse.radmin; + + let analytic = ellipse.value(&store, u, 0.0, Some(radmin)); + + let h = 1e-6; + store.set(radmin, 4.0 + h); + let plus = ellipse.value(&store, u, 0.0, None); + store.set(radmin, 4.0 - h); + let minus = ellipse.value(&store, u, 0.0, None); + + assert!((analytic.dx - (plus.x - minus.x) / (2.0 * h)).abs() < 1e-6); + assert!((analytic.dy - (plus.y - minus.y) / (2.0 * h)).abs() < 1e-6); + } + + #[test] + fn arc_of_ellipse_delegates_to_its_ellipse() { + let mut store = ParamStore::new(); + let ellipse = make_ellipse(&mut store, 0.0, 0.0, 3.0, 4.0); + let arc = ArcOfEllipse { + ellipse, + start: make_point(&mut store, 5.0, 0.0), + end: make_point(&mut store, 0.0, 4.0), + start_angle: store.add(0.0, false), + end_angle: store.add(std::f64::consts::FRAC_PI_2, false), + }; + let v = arc.value(&store, 0.0, 0.0, None); + assert!((v.x - 5.0).abs() < EPS && v.y.abs() < EPS); + } + + #[test] + fn hyperbola_value_satisfies_the_hyperbola_equation() { + let mut store = ParamStore::new(); + // c=5, b=4 -> a=3 (3-4-5 triangle keeps this exact in f64). + let hyperbola = Hyperbola { + center: make_point(&mut store, 0.0, 0.0), + focus1: make_point(&mut store, 5.0, 0.0), + radmin: store.add(4.0, false), + }; + let (a, b) = (3.0, 4.0); + + for &u in &[0.0, 0.5, -0.8, 1.3] { + let v = hyperbola.value(&store, u, 0.0, None); + let lhs = (v.x / a).powi(2) - (v.y / b).powi(2); + assert!((lhs - 1.0).abs() < 1e-9, "u={u}: off the hyperbola, lhs={lhs}"); + } + } + + #[test] + fn parabola_value_satisfies_the_parabola_equation() { + let mut store = ParamStore::new(); + // Vertex at origin, focus at (0, f) along +y. + let f = 2.0; + let parabola = Parabola { vertex: make_point(&mut store, 0.0, 0.0), focus1: make_point(&mut store, 0.0, f) }; + + for &u in &[0.0, 1.0, -2.0, 3.5] { + let v = parabola.value(&store, u, 0.0, None); + // Value()'s xdir is the unit vector from vertex TOWARD the + // focus (here +y), with ydir = xdir rotated 90° ccw (here -x), + // so this local frame gives (x, y) = (-u, u^2/(4f)), not (u, ...). + assert!((v.x - (-u)).abs() < EPS); + assert!((v.y - u * u / (4.0 * f)).abs() < EPS); + // The parabola equation itself (x^2 = 4fy) only depends on x^2, + // so it holds regardless of that sign. + assert!((v.x * v.x - 4.0 * f * v.y).abs() < EPS, "u={u}: off the parabola"); + } + } + + /// A degree-3, all-weights-1, fully-clamped 4-pole B-spline (knots + /// `[0,0,0,0,1,1,1,1]`) is exactly a cubic Bézier curve over the four + /// poles — a closed-form oracle independent of `spline_value`'s own + /// recursion. + fn make_cubic_bezier_as_bspline(store: &mut ParamStore, poles_xy: [(f64, f64); 4]) -> BSpline { + let poles: Vec = poles_xy.iter().map(|&(x, y)| make_point(store, x, y)).collect(); + let weights = vec![store.add(1.0, false); 4]; + BSpline { + start: poles[0], + end: poles[3], + poles, + weights, + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + degree: 3, + periodic: false, + } + } + + fn cubic_bezier_point(poles: [(f64, f64); 4], u: f64) -> (f64, f64) { + let b = [ + (1.0 - u).powi(3), + 3.0 * u * (1.0 - u).powi(2), + 3.0 * u * u * (1.0 - u), + u.powi(3), + ]; + let x = (0..4).map(|i| b[i] * poles[i].0).sum(); + let y = (0..4).map(|i| b[i] * poles[i].1).sum(); + (x, y) + } + + #[test] + fn bspline_value_matches_the_closed_form_cubic_bezier() { + let mut store = ParamStore::new(); + let poles_xy = [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]; + let bsp = make_cubic_bezier_as_bspline(&mut store, poles_xy); + + for &u in &[0.0, 0.25, 0.5, 0.75, 1.0] { + let v = bsp.value(&store, u, 0.0, None); + let (ex, ey) = cubic_bezier_point(poles_xy, u); + assert!((v.x - ex).abs() < EPS, "u={u}: x mismatch, got {} want {ex}", v.x); + assert!((v.y - ey).abs() < EPS, "u={u}: y mismatch, got {} want {ey}", v.y); + } + } + + #[test] + fn bspline_value_derivative_wrt_u_matches_finite_difference() { + let mut store = ParamStore::new(); + let bsp = make_cubic_bezier_as_bspline(&mut store, [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]); + let u = 0.4_f64; + let h = 1e-6; + + let analytic = bsp.value(&store, u, 0.0, None); + let plus = bsp.value(&store, u + h, 0.0, None); + let minus = bsp.value(&store, u - h, 0.0, None); + assert!((analytic.dx - (plus.x - minus.x) / (2.0 * h)).abs() < 1e-5); + assert!((analytic.dy - (plus.y - minus.y) / (2.0 * h)).abs() < 1e-5); + } + + #[test] + fn bspline_normal_at_start_and_end_matches_first_and_last_pole_pair() { + let mut store = ParamStore::new(); + let poles_xy = [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]; + let bsp = make_cubic_bezier_as_bspline(&mut store, poles_xy); + + // Query at the exact stored `start`/`end` points (not a freshly + // computed `value()`, which could differ by float roundoff and so + // fail `calculate_normal_from_value`'s exact-equality check). + let n_start = bsp.calculate_normal(&store, bsp.start, None); + // Tangent at start is pole1 - pole0 = (1,3); normal is that rotated 90ccw = (-3,1). + assert!((n_start.x - (-3.0)).abs() < EPS && (n_start.y - 1.0).abs() < EPS, "{n_start:?}"); + + let n_end = bsp.calculate_normal(&store, bsp.end, None); + // Tangent at end is pole3 - pole2 = (1,-3); normal is that rotated 90ccw = (3,1). + assert!((n_end.x - 3.0).abs() < EPS && (n_end.y - 1.0).abs() < EPS, "{n_end:?}"); + } + + #[test] + fn bspline_normal_at_param_matches_the_tangent_direction() { + let mut store = ParamStore::new(); + let bsp = make_cubic_bezier_as_bspline(&mut store, [(0.0, 0.0), (1.0, 3.0), (3.0, 3.0), (4.0, 0.0)]); + let u = 0.5; + + let v = bsp.value(&store, u, 1.0, None); // dx,dy = tangent direction + let n = bsp.calculate_normal_at_param(&store, u, None); + // Normal is the tangent rotated 90ccw: (-dy, dx). + let tangent_len = (v.dx * v.dx + v.dy * v.dy).sqrt(); + assert!((n.x / n.x.hypot(n.y) - (-v.dy / tangent_len)).abs() < 1e-6); + assert!((n.y / n.x.hypot(n.y) - (v.dx / tangent_len)).abs() < 1e-6); + } +} diff --git a/crates/ocs_gcs/src/graph.rs b/crates/ocs_gcs/src/graph.rs new file mode 100644 index 000000000..bd0cba20b --- /dev/null +++ b/crates/ocs_gcs/src/graph.rs @@ -0,0 +1,140 @@ +//! Partitions a constraint list into independent, decoupled groups so a +//! solver can solve each separately. +//! +//! planegcs does this (`GCS.cpp`, `System::initSolution`) by building a +//! bipartite graph — one vertex per free parameter, one per constraint, an +//! edge from a constraint to each parameter it touches — and running +//! Boost.Graph's `connected_components` over it. The result is exactly the +//! partition a plain union-find over parameters gives directly: two +//! constraints end up in the same group iff they share a parameter, +//! transitively through a chain of other constraints. That's what this does +//! instead, without pulling in a graph crate for one algorithm. + +use std::collections::HashMap; + +use crate::constraints::Constraint; +use crate::util::ParamId; + +struct UnionFind { + parent: Vec, +} + +impl UnionFind { + fn new(n: usize) -> Self { + Self { parent: (0..n).collect() } + } + + fn find(&mut self, x: usize) -> usize { + if self.parent[x] != x { + self.parent[x] = self.find(self.parent[x]); + } + self.parent[x] + } + + fn union(&mut self, a: usize, b: usize) { + let (ra, rb) = (self.find(a), self.find(b)); + if ra != rb { + self.parent[ra] = rb; + } + } +} + +/// Groups constraint indices (`0..constraints.len()`) by shared parameters. +/// A constraint with no parameters gets its own singleton group. Group +/// order, and the order of indices within each group, are unspecified. +pub fn partition_by_shared_params(constraints: &[std::rc::Rc]) -> Vec> { + let n = constraints.len(); + let mut uf = UnionFind::new(n); + let mut first_seen: HashMap = HashMap::new(); + + for (i, constr) in constraints.iter().enumerate() { + for param in constr.params() { + match first_seen.get(¶m) { + Some(&j) => uf.union(i, j), + None => { + first_seen.insert(param, i); + } + } + } + } + + let mut groups: HashMap> = HashMap::new(); + for i in 0..n { + groups.entry(uf.find(i)).or_default().push(i); + } + groups.into_values().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::point_line::Difference; + use crate::util::ParamStore; + use std::rc::Rc; + + fn sorted(mut groups: Vec>) -> Vec> { + for g in &mut groups { + g.sort(); + } + groups.sort(); + groups + } + + #[test] + fn constraints_sharing_a_param_land_in_one_group() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, false); + let c = store.add(3.0, false); + let d1 = store.add(0.0, false); + let d2 = store.add(0.0, false); + + let constraints: Vec> = vec![ + Rc::new(Difference::new(a, b, d1)), // touches a, b + Rc::new(Difference::new(b, c, d2)), // touches b, c -- links to the first via b + ]; + + let groups = sorted(partition_by_shared_params(&constraints)); + assert_eq!(groups, vec![vec![0, 1]]); + } + + #[test] + fn disjoint_constraints_land_in_separate_groups() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, false); + let c = store.add(3.0, false); + let e = store.add(4.0, false); + let d1 = store.add(0.0, false); + let d2 = store.add(0.0, false); + + let constraints: Vec> = vec![ + Rc::new(Difference::new(a, b, d1)), + Rc::new(Difference::new(c, e, d2)), + ]; + + let groups = sorted(partition_by_shared_params(&constraints)); + assert_eq!(groups, vec![vec![0], vec![1]]); + } + + #[test] + fn a_chain_of_shared_params_transitively_joins_three_constraints() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, false); + let c = store.add(3.0, false); + let d = store.add(4.0, false); + let d1 = store.add(0.0, false); + let d2 = store.add(0.0, false); + let d3 = store.add(0.0, false); + + let constraints: Vec> = vec![ + Rc::new(Difference::new(a, b, d1)), // a, b + Rc::new(Difference::new(c, d, d2)), // c, d -- separate so far + Rc::new(Difference::new(b, c, d3)), // b, c -- bridges the two groups above + ]; + + let groups = sorted(partition_by_shared_params(&constraints)); + assert_eq!(groups, vec![vec![0, 1, 2]]); + } +} diff --git a/crates/ocs_gcs/src/lib.rs b/crates/ocs_gcs/src/lib.rs new file mode 100644 index 000000000..1f01c8e5d --- /dev/null +++ b/crates/ocs_gcs/src/lib.rs @@ -0,0 +1,15 @@ +//! 2D geometric constraint solver for OpenCADStudio sketches. +//! +//! A Rust port of FreeCAD's planegcs (LGPL-2.1-or-later), staged bottom-up: +//! see `/Users/felix/.claude/plans/snoopy-finding-matsumoto.md` for the full +//! port plan and stage order. + +pub mod constraints; +pub mod diagnosis; +pub mod geo; +pub mod graph; +pub mod qp_eq; +pub mod solvers; +pub mod subsystem; +pub mod system; +pub mod util; diff --git a/crates/ocs_gcs/src/qp_eq.rs b/crates/ocs_gcs/src/qp_eq.rs new file mode 100644 index 000000000..8aac36b88 --- /dev/null +++ b/crates/ocs_gcs/src/qp_eq.rs @@ -0,0 +1,182 @@ +//! Equality-constrained quadratic program: minimizes `0.5*xᵀHx + gᵀx` +//! subject to `Ax + c = 0`. +//! +//! Ported from planegcs's `qp_eq.h`/`qp_eq.cpp`. The C++ takes a +//! `FullPivHouseholderQR` of `Aᵀ` and reads its *full* square `Q`, splitting +//! its columns by rank into a basis for `A`'s row space and one for its null +//! space. nalgebra's `QR` (like its `SVD`) only ever returns the *thin* +//! Q/U — `params_num × rank`, not `params_num × params_num` — so there are +//! no extra columns to split off directly. This gets the same two bases a +//! different way: `Y` (the row-space part) via the equivalent minimum-norm +//! right inverse `Y = Aᵀ(AAᵀ)⁻¹` (Cholesky, since `AAᵀ` is symmetric +//! positive-definite for full-row-rank `A`), and `Z` (the null-space part) +//! by extending the thin Q's `rank` orthonormal columns to a full +//! orthonormal basis of R^params_num via Gram-Schmidt against the standard +//! basis, keeping only the newly-added vectors. Both give the same +//! projectors the C++'s single QR produces — the KKT system solved here has +//! a unique minimizer regardless of which orthonormal basis the row/null +//! space happen to be expressed in. + +use nalgebra::{DMatrix, DVector}; + +/// Solution of the equality-constrained QP: the minimizer `x`, the row-space +/// basis `y`, and the null-space basis `z` of `A` (mirroring the C++'s +/// output parameters `x`, `Y`, `Z`). +pub struct QpEqSolution { + pub x: DVector, + pub y: DMatrix, + pub z: DMatrix, +} + +/// Returns `None` where the C++ returns `-1`: `A` doesn't have full row rank, +/// or has more constraints than parameters. +pub fn qp_eq(h: &DMatrix, g: &DVector, a: &DMatrix, c: &DVector) -> Option { + let constr_num = a.nrows(); + let params_num = a.ncols(); + if constr_num > params_num { + return None; + } + + // Rank check only needs the singular values, not the vectors — cheaper + // than a full SVD and mirrors the C++'s `rank != constr_num` guard. + let singular_values = a.clone().singular_values(); + let tol = singular_values.max() * f64::EPSILON * (params_num.max(constr_num) as f64); + let rank = singular_values.iter().filter(|&&s| s > tol).count(); + if rank != constr_num { + return None; + } + + // Thin QR of A^T: Q1 is params_num x rank, an orthonormal basis of A's + // row space (A^T's column space) — well-posed since A's already-confirmed + // full row rank means A^T has full column rank. + let qr = a.transpose().qr(); + let q1 = qr.q(); + + // Z: null-space basis of A, found by extending Q1's columns to a full + // orthonormal basis of R^params_num and keeping only the new vectors — + // mirrors `Z = Q.rightCols(params_num - rank)` from the full square Q + // the C++ has directly. + let z = extend_to_orthonormal_basis(&q1, params_num); + + // Y = A^T (A A^T)^-1, the minimum-norm right inverse of full-row-rank A + // — mirrors `Y = Q1 * inv(R1^T) * P^T`, the same row-space projector. + let aat = a * a.transpose(); + let aat_chol = nalgebra::linalg::Cholesky::new(aat)?; + let aat_inv = aat_chol.solve(&DMatrix::identity(constr_num, constr_num)); + let y = a.transpose() * &aat_inv; + + let x = if params_num == rank { + -&y * c + } else { + let ztz = z.transpose() * h * &z; + let rhs = z.transpose() * (h * &y * c - g); + let y_step = ztz.lu().solve(&rhs)?; + -&y * c + &z * y_step + }; + + Some(QpEqSolution { x, y, z }) +} + +/// Extends the orthonormal columns of `q1` (`dim x rank`) to a full +/// orthonormal basis of R^dim via modified Gram-Schmidt against the standard +/// basis vectors, returning only the newly-added `dim - rank` columns. +fn extend_to_orthonormal_basis(q1: &DMatrix, dim: usize) -> DMatrix { + const TOL: f64 = 1e-10; + let rank = q1.ncols(); + if dim == rank { + return DMatrix::zeros(dim, 0); + } + let mut basis: Vec> = (0..rank).map(|i| q1.column(i).into_owned()).collect(); + let mut extra: Vec> = Vec::with_capacity(dim - rank); + + for i in 0..dim { + if extra.len() == dim - rank { + break; + } + let mut candidate = DVector::zeros(dim); + candidate[i] = 1.0; + for b in &basis { + let proj = b.dot(&candidate); + candidate -= b * proj; + } + let norm = candidate.norm(); + if norm > TOL { + candidate /= norm; + basis.push(candidate.clone()); + extra.push(candidate); + } + } + + DMatrix::from_columns(&extra) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPS: f64 = 1e-8; + + #[test] + fn minimizes_sum_of_squares_on_a_line() { + // minimize x1^2 + x2^2 s.t. x1 + x2 = 1 -> x1 = x2 = 0.5 + let h = DMatrix::identity(2, 2) * 2.0; + let g = DVector::from_vec(vec![0.0, 0.0]); + let a = DMatrix::from_row_slice(1, 2, &[1.0, 1.0]); + let c = DVector::from_vec(vec![-1.0]); + + let sol = qp_eq(&h, &g, &a, &c).expect("full row rank system should solve"); + assert!((sol.x[0] - 0.5).abs() < EPS); + assert!((sol.x[1] - 0.5).abs() < EPS); + } + + #[test] + fn fully_determined_system_matches_the_direct_solution() { + // Square A: no null space (params_num == rank), x = -Y*c is the whole answer. + // x1 + x2 = 3, x1 - x2 = 1 -> x1=2, x2=1 + let h = DMatrix::identity(2, 2); + let g = DVector::from_vec(vec![0.0, 0.0]); + let a = DMatrix::from_row_slice(2, 2, &[1.0, 1.0, 1.0, -1.0]); + let c = DVector::from_vec(vec![-3.0, -1.0]); + + let sol = qp_eq(&h, &g, &a, &c).expect("square full-rank system should solve"); + assert!((sol.x[0] - 2.0).abs() < EPS); + assert!((sol.x[1] - 1.0).abs() < EPS); + } + + #[test] + fn rank_deficient_constraints_return_none() { + // Two identical rows: rank(A) = 1 != constr_num = 2. + let h = DMatrix::identity(2, 2); + let g = DVector::from_vec(vec![0.0, 0.0]); + let a = DMatrix::from_row_slice(2, 2, &[1.0, 1.0, 1.0, 1.0]); + let c = DVector::from_vec(vec![-1.0, -1.0]); + + assert!(qp_eq(&h, &g, &a, &c).is_none()); + } + + #[test] + fn more_constraints_than_parameters_return_none() { + let h = DMatrix::identity(1, 1); + let g = DVector::from_vec(vec![0.0]); + let a = DMatrix::from_row_slice(2, 1, &[1.0, 1.0]); + let c = DVector::from_vec(vec![-1.0, -1.0]); + + assert!(qp_eq(&h, &g, &a, &c).is_none()); + } + + #[test] + fn z_spans_the_null_space_of_a() { + let h = DMatrix::identity(3, 3); + let g = DVector::from_vec(vec![0.0, 0.0, 0.0]); + let a = DMatrix::from_row_slice(1, 3, &[1.0, 1.0, 1.0]); + let c = DVector::from_vec(vec![0.0]); + + let sol = qp_eq(&h, &g, &a, &c).unwrap(); + assert_eq!(sol.z.nrows(), 3); + assert_eq!(sol.z.ncols(), 2); + let should_be_zero = &a * &sol.z; + for v in should_be_zero.iter() { + assert!(v.abs() < EPS, "A*Z should be zero, got {should_be_zero}"); + } + } +} diff --git a/crates/ocs_gcs/src/solvers/bfgs.rs b/crates/ocs_gcs/src/solvers/bfgs.rs new file mode 100644 index 000000000..2324262aa --- /dev/null +++ b/crates/ocs_gcs/src/solvers/bfgs.rs @@ -0,0 +1,169 @@ +//! BFGS — quasi-Newton with a numerically-approximated inverse Hessian. +//! +//! Ported from `System::solve_BFGS` and the free function `lineSearch` +//! (`GCS.cpp`). Defaults match planegcs's own: `convergence = 1e-10` +//! (`System::System()`), `smallF = 1e-20` (`#define smallF 1e-20`, +//! `GCS.h`), `max_iter = 100`. As with `dogleg.rs`/`lm.rs`, store snapshot/ +//! revert-on-failure replaces the C++'s scratch-copy-plus-`applySolution()` +//! split — see `solvers/mod.rs`'s doc comment. + +use nalgebra::DVector; + +use crate::solvers::SolveStatus; +use crate::subsystem::SubSystem; +use crate::util::ParamStore; + +const CONVERGENCE: f64 = 1e-10; +const SMALL_F: f64 = 1e-20; +const MAX_ITER: usize = 100; + +/// Bracket-and-quadratic-fit line search along `xdir`, restricted to +/// `subsys->maxStep`. Leaves `store` holding the params at the chosen step +/// and returns that step's scale — ported from the free function +/// `lineSearch` (`GCS.cpp`). +fn line_search(sub: &SubSystem, store: &mut ParamStore, xdir: &DVector) -> f64 { + let alpha_max = sub.max_step(store, xdir.as_slice(), 1e10); + let x0 = sub.get_params(store); + + let alpha1 = 0.0_f64; + let f1 = sub.error(store); + + let mut alpha2 = 1.0_f64; + sub.set_params(store, &(&x0 + xdir * alpha2)); + let mut f2 = sub.error(store); + + let mut alpha3 = alpha2 * 2.0; + sub.set_params(store, &(&x0 + xdir * alpha3)); + let mut f3 = sub.error(store); + + while f2 > f1 || f2 > f3 { + if f2 > f1 { + alpha3 = alpha2; + f3 = f2; + alpha2 /= 2.0; + sub.set_params(store, &(&x0 + xdir * alpha2)); + f2 = sub.error(store); + } else if f2 > f3 { + if alpha3 >= alpha_max { + break; + } + alpha2 = alpha3; + f2 = f3; + alpha3 *= 2.0; + sub.set_params(store, &(&x0 + xdir * alpha3)); + f3 = sub.error(store); + } + } + + let mut alpha_star = alpha2 + ((alpha2 - alpha1) * (f1 - f3)) / (3.0 * (f1 - 2.0 * f2 + f3)); + if alpha_star >= alpha3 || alpha_star <= alpha1 { + alpha_star = alpha2; + } + if alpha_star > alpha_max { + alpha_star = alpha_max; + } + if alpha_star.is_nan() { + alpha_star = 0.0; + } + + sub.set_params(store, &(&x0 + xdir * alpha_star)); + alpha_star +} + +pub fn solve_bfgs(sub: &SubSystem, store: &mut ParamStore) -> SolveStatus { + let xsize = sub.p_size(); + if xsize == 0 { + return SolveStatus::Success; + } + + let snapshot = store.redirect(); + + let mut d = nalgebra::DMatrix::::identity(xsize, xsize); + + let mut x = sub.get_params(store); + let mut grad = sub.calc_grad(store); + + let mut xdir = -&grad; + line_search(sub, store, &xdir); + let mut err = sub.error(store); + + let mut h = &sub.get_params(store) - &x; + x = sub.get_params(store); + + let diverging_lim = 1e6 * err + 1e12; + + for _iter in 1..MAX_ITER { + let h_norm = h.norm(); + if h_norm <= CONVERGENCE || err <= SMALL_F { + break; + } + if err > diverging_lim || err.is_nan() { + break; + } + + let grad_old = grad.clone(); + grad = sub.calc_grad(store); + let y = &grad - &grad_old; + + let mut hty = h.dot(&y); + if hty == 0.0 { + hty = 0.0000000001; + } + + let dy = &d * &y; + let yt_dy = y.dot(&dy); + + d += (&h * h.transpose()) * ((1.0 + yt_dy / hty) / hty); + d -= (&h * dy.transpose() + &dy * h.transpose()) * (1.0 / hty); + + xdir = -(&d * &grad); + line_search(sub, store, &xdir); + err = sub.error(store); + + h = &sub.get_params(store) - &x; + x = sub.get_params(store); + } + + if err <= SMALL_F { + return SolveStatus::Success; + } + if h.norm() <= CONVERGENCE { + return SolveStatus::Converged; + } + store.revert(&snapshot); + SolveStatus::Failed +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::point_line::P2PDistance; + use crate::geo::Point; + use std::rc::Rc; + + #[test] + fn solves_a_single_p2p_distance_by_moving_the_free_point() { + let mut store = ParamStore::new(); + let p1 = Point::new(store.add(0.0, true), store.add(0.0, true)); + let p2 = Point::new(store.add(1.0, false), store.add(0.0, false)); + let distance = store.add(5.0, true); + + let constr: Rc = Rc::new(P2PDistance::new(p1, p2, distance)); + let sub = SubSystem::new(vec![constr], &[p2.x, p2.y]); + + let status = solve_bfgs(&sub, &mut store); + assert!(matches!(status, SolveStatus::Success | SolveStatus::Converged), "expected Success or Converged, got {status:?}"); + assert!(sub.error(&store).abs() < 1e-6); + + let dx = store.get(p1.x) - store.get(p2.x); + let dy = store.get(p1.y) - store.get(p2.y); + assert!(((dx * dx + dy * dy).sqrt() - 5.0).abs() < 1e-4); + } + + #[test] + fn a_zero_parameter_subsystem_trivially_succeeds() { + let mut store = ParamStore::new(); + let sub = SubSystem::new(vec![], &[]); + assert_eq!(solve_bfgs(&sub, &mut store), SolveStatus::Success); + } +} diff --git a/crates/ocs_gcs/src/solvers/dogleg.rs b/crates/ocs_gcs/src/solvers/dogleg.rs new file mode 100644 index 000000000..9150e45f9 --- /dev/null +++ b/crates/ocs_gcs/src/solvers/dogleg.rs @@ -0,0 +1,209 @@ +//! Powell's Dogleg trust-region method — planegcs's default solver. +//! +//! Ported from `System::solve_DL` (`GCS.cpp`). Tolerances and iteration cap +//! match planegcs's own defaults (`System::System()`'s member-initializer +//! list): `tolg = 1e-80`, `tolx = 1e-80`, `tolf = 1e-10`, `max_iter = 100` +//! (the "redundant-solving" variants with different tolerances, and the +//! `sketchSizeMultiplier`-scaled iteration cap, aren't exposed — both are +//! System-level solver *configuration* the later `diagnosis.rs`/`system.rs` +//! work can thread through if it turns out to matter). +//! +//! One deliberate substitution: the Gauss-Newton step `h_gn` solves +//! `Jx * h_gn = -fx`. The C++ defaults to Eigen's `FullPivLU`, chosen +//! because it handles `Jx` being rectangular and possibly rank-deficient. +//! nalgebra has no equivalent decomposition; this uses its SVD-based +//! least-squares solve instead, which handles the same rectangular/ +//! rank-deficient cases (via a small-singular-value cutoff) and serves the +//! same purpose — a robust direction when the Jacobian isn't a nice square +//! invertible matrix — without replicating the C++'s three-way +//! `dogLegGaussStep` switch between LU variants. + +use nalgebra::DVector; + +use crate::solvers::SolveStatus; +use crate::subsystem::SubSystem; +use crate::util::ParamStore; + +const TOLG: f64 = 1e-80; +const TOLX: f64 = 1e-80; +const TOLF: f64 = 1e-10; +const MAX_ITER: usize = 100; + +pub fn solve_dl(sub: &SubSystem, store: &mut ParamStore) -> SolveStatus { + let xsize = sub.p_size(); + if xsize == 0 { + return SolveStatus::Success; + } + + let snapshot = store.redirect(); + + let mut x = sub.get_params(store); + let mut fx = sub.calc_residual(store); + let mut jx = sub.calc_jacobi(store); + let mut err = sub.error(store); + + let mut g = jx.transpose() * (-fx.clone()); + let mut g_inf = g.amax(); + let mut fx_inf = fx.amax(); + + let diverging_lim = 1e6 * err + 1e12; + let mut delta = 0.1_f64; + let mut nu = 2.0_f64; + let mut iter = 0usize; + let mut reduce = 0i32; + let mut stop = 0u8; + + loop { + if fx_inf <= TOLF { + stop = 1; + break; + } else if g_inf <= TOLG { + stop = 2; + break; + } else if delta <= TOLX * (TOLX + x.norm()) { + stop = 2; + break; + } else if iter >= MAX_ITER { + stop = 4; + break; + } else if err > diverging_lim || err.is_nan() { + stop = 6; + break; + } + + let alpha = g.norm_squared() / (&jx * &g).norm_squared(); + let h_sd = &g * alpha; + + let h_gn = jx + .clone() + .svd(true, true) + .solve(&(-fx.clone()), 1e-12) + .unwrap_or_else(|_| DVector::zeros(xsize)); + + let rel_error = (&jx * &h_gn + &fx).norm() / fx.norm(); + if rel_error > 1e15 { + break; // stop stays 0 -> Failed, matching the C++'s bare `break;` here + } + + let h_dl; + if h_gn.norm() < delta { + h_dl = h_gn.clone(); + if h_dl.norm() <= TOLX * (TOLX + x.norm()) { + stop = 5; + break; + } + } else if alpha * g.norm() >= delta { + h_dl = &h_sd * (delta / (alpha * g.norm())); + } else { + let b = &h_gn - &h_sd; + let bb = b.norm_squared(); + let gb = h_sd.dot(&b).abs(); + let c = (delta + h_sd.norm()) * (delta - h_sd.norm()); + let beta = if gb > 0.0 { c / (gb + (gb * gb + c * bb).sqrt()) } else { ((gb * gb + c * bb).sqrt() - gb) / bb }; + h_dl = &h_sd + &b * beta; + } + + let x_new = &x + &h_dl; + sub.set_params(store, &x_new); + let fx_new = sub.calc_residual(store); + let err_new = sub.error(store); + let jx_new = sub.calc_jacobi(store); + + let dl_pred = err - 0.5 * (&fx + &jx * &h_dl).norm_squared(); + let df = err - err_new; + let rho = dl_pred / df; + + if df > 0.0 && dl_pred > 0.0 { + x = x_new; + jx = jx_new; + fx = fx_new; + err = err_new; + g = jx.transpose() * (-fx.clone()); + g_inf = g.amax(); + fx_inf = fx.amax(); + } else { + // The trial step wasn't accepted: undo it. The C++ never wrote + // it anywhere but its own scratch copy in the first place; this + // port has no such scratch, so the store needs restoring to the + // last accepted `x` explicitly. + sub.set_params(store, &x); + } + + if (rho - 1.0).abs() < 0.2 && h_dl.norm() > delta / 3.0 && reduce <= 0 { + delta *= 3.0; + nu = 2.0; + reduce = 0; + } else if rho < 0.25 { + delta /= nu; + nu *= 2.0; + reduce = 2; + } else { + reduce -= 1; + } + + iter += 1; + } + + if stop != 1 { + store.revert(&snapshot); + return SolveStatus::Failed; + } + SolveStatus::Success +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::point_line::P2PDistance; + use crate::geo::Point; + use std::rc::Rc; + + #[test] + fn solves_a_single_p2p_distance_by_moving_the_free_point() { + let mut store = ParamStore::new(); + let p1 = Point::new(store.add(0.0, true), store.add(0.0, true)); // fixed anchor + let p2 = Point::new(store.add(1.0, false), store.add(0.0, false)); // free point, wrong distance + let distance = store.add(5.0, true); // driven target + + let constr: Rc = Rc::new(P2PDistance::new(p1, p2, distance)); + let sub = SubSystem::new(vec![constr], &[p2.x, p2.y]); + + assert!(sub.error(&store) > 0.0, "should start unsatisfied"); + let status = solve_dl(&sub, &mut store); + assert_eq!(status, SolveStatus::Success); + assert!(sub.error(&store) < 1e-15, "residual should be ~zero after solving"); + + let dx = store.get(p1.x) - store.get(p2.x); + let dy = store.get(p1.y) - store.get(p2.y); + assert!(((dx * dx + dy * dy).sqrt() - 5.0).abs() < 1e-7); + } + + #[test] + fn solves_a_two_constraint_triangle() { + // Two points free, each constrained to a fixed distance from a + // fixed anchor and from each other -- a small but non-trivial + // coupled system. + let mut store = ParamStore::new(); + let anchor = Point::new(store.add(0.0, true), store.add(0.0, true)); + let p = Point::new(store.add(1.0, false), store.add(1.0, false)); + let d_anchor = store.add(10.0, true); + + let c1: Rc = Rc::new(P2PDistance::new(anchor, p, d_anchor)); + let sub = SubSystem::new(vec![c1], &[p.x, p.y]); + + let status = solve_dl(&sub, &mut store); + assert_eq!(status, SolveStatus::Success); + + let dx = store.get(anchor.x) - store.get(p.x); + let dy = store.get(anchor.y) - store.get(p.y); + assert!(((dx * dx + dy * dy).sqrt() - 10.0).abs() < 1e-7); + } + + #[test] + fn a_zero_parameter_subsystem_trivially_succeeds() { + let store = ParamStore::new(); + let sub = SubSystem::new(vec![], &[]); + let mut store = store; + assert_eq!(solve_dl(&sub, &mut store), SolveStatus::Success); + } +} diff --git a/crates/ocs_gcs/src/solvers/lm.rs b/crates/ocs_gcs/src/solvers/lm.rs new file mode 100644 index 000000000..a34445a0a --- /dev/null +++ b/crates/ocs_gcs/src/solvers/lm.rs @@ -0,0 +1,176 @@ +//! Levenberg-Marquardt — damped Gauss-Newton least squares. +//! +//! Ported from `System::solve_LM` (`GCS.cpp`). Defaults match planegcs's +//! own (`System::System()`): `eps = 1e-10`, `eps1 = 1e-80`, `tau = 1e-3`, +//! `max_iter = 100` (as with `dogleg.rs`, the "redundant-solving" tolerance +//! variants and the `sketchSizeMultiplier`-scaled iteration cap aren't +//! exposed here). Store snapshot/revert-on-failure replaces the C++'s +//! scratch-copy-plus-separate-`applySolution()` pattern exactly as in +//! `dogleg.rs` — see that module's doc comment and `subsystem.rs`'s for why. +//! +//! One faithful-but-odd detail kept as-is: the inner damping-adjustment loop +//! counts rejected steps in `k` and stops after `k` reaches 50, but the +//! C++'s `if (k > 50) { stop = 7; ... }` check after that loop can never be +//! true (`k` never exceeds 50, only reaches it) — so that branch is +//! effectively dead code upstream, and stays dead code here rather than +//! being "corrected" into the probably-intended `k >= 50`. + +use crate::solvers::SolveStatus; +use crate::subsystem::SubSystem; +use crate::util::ParamStore; + +const EPS: f64 = 1e-10; +const EPS1: f64 = 1e-80; +const TAU: f64 = 1e-3; +const MAX_ITER: usize = 100; + +pub fn solve_lm(sub: &SubSystem, store: &mut ParamStore) -> SolveStatus { + let xsize = sub.p_size(); + if xsize == 0 { + return SolveStatus::Success; + } + + let snapshot = store.redirect(); + + let mut x = sub.get_params(store); + let mut e = -sub.calc_residual(store); + + let diverging_lim = 1e6 * e.norm_squared() + 1e12; + let mut nu = 2.0_f64; + let mut mu = 0.0_f64; + let mut stop = 0u8; + + 'outer: for iter in 0..MAX_ITER { + let err = e.norm_squared(); + if err <= EPS * EPS { + stop = 1; + break; + } else if err > diverging_lim || err.is_nan() { + stop = 6; + break; + } + + let j = sub.calc_jacobi(store); + let mut a = j.transpose() * &j; + let g = j.transpose() * &e; + + let g_inf = g.amax(); + let diag_a = a.diagonal().clone_owned(); + + if g_inf <= EPS1 { + stop = 2; + break; + } + + if iter == 0 { + mu = TAU * diag_a.amax(); + } + + let mut k = 0u32; + loop { + if k >= 50 { + break; + } + + for i in 0..xsize { + a[(i, i)] += mu; + } + + let h = a.clone().lu().solve(&g).unwrap_or_else(|| nalgebra::DVector::zeros(xsize)); + let rel_error = (&a * &h - &g).norm() / g.norm(); + + if rel_error < 1e-5 { + let scale = sub.max_step(store, h.as_slice(), 1e10); + let h = if scale < 1.0 { &h * scale } else { h }; + + let x_new = &x + &h; + let h_norm = h.norm_squared(); + + if h_norm <= EPS1 * EPS1 * x.norm() { + stop = 3; + break; + } else if h_norm >= (x.norm() + EPS1) / (f64::EPSILON * f64::EPSILON) { + stop = 4; + break; + } + + sub.set_params(store, &x_new); + let e_new = -sub.calc_residual(store); + + let df = e.norm_squared() - e_new.norm_squared(); + let dl = h.dot(&(&h * mu + &g)); + + if df > 0.0 && dl > 0.0 { + let tmp = 2.0 * df / dl - 1.0; + mu *= (1.0 / 3.0_f64).max(1.0 - tmp * tmp * tmp); + nu = 2.0; + x = x_new; + e = e_new; + break; + } + // Rejected: undo the trial write into the store (no + // separate scratch copy here to have kept it out of in the + // first place -- see the module doc). + sub.set_params(store, &x); + } + + // Rejected (or the linear solve was unreliable): back off and + // retry with a fresh damping factor. + mu *= nu; + nu *= 2.0; + for i in 0..xsize { + a[(i, i)] = diag_a[i]; + } + k += 1; + } + + if stop != 0 { + break 'outer; + } + if k > 50 { + // Dead code upstream (see module doc) -- kept for fidelity. + stop = 7; + break 'outer; + } + } + + if stop != 1 { + store.revert(&snapshot); + return SolveStatus::Failed; + } + SolveStatus::Success +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::point_line::P2PDistance; + use crate::geo::Point; + use std::rc::Rc; + + #[test] + fn solves_a_single_p2p_distance_by_moving_the_free_point() { + let mut store = ParamStore::new(); + let p1 = Point::new(store.add(0.0, true), store.add(0.0, true)); + let p2 = Point::new(store.add(1.0, false), store.add(0.0, false)); + let distance = store.add(5.0, true); + + let constr: Rc = Rc::new(P2PDistance::new(p1, p2, distance)); + let sub = SubSystem::new(vec![constr], &[p2.x, p2.y]); + + let status = solve_lm(&sub, &mut store); + assert_eq!(status, SolveStatus::Success); + assert!(sub.error(&store).abs() < 1e-9); + + let dx = store.get(p1.x) - store.get(p2.x); + let dy = store.get(p1.y) - store.get(p2.y); + assert!(((dx * dx + dy * dy).sqrt() - 5.0).abs() < 1e-6); + } + + #[test] + fn a_zero_parameter_subsystem_trivially_succeeds() { + let mut store = ParamStore::new(); + let sub = SubSystem::new(vec![], &[]); + assert_eq!(solve_lm(&sub, &mut store), SolveStatus::Success); + } +} diff --git a/crates/ocs_gcs/src/solvers/mod.rs b/crates/ocs_gcs/src/solvers/mod.rs new file mode 100644 index 000000000..a04ee0538 --- /dev/null +++ b/crates/ocs_gcs/src/solvers/mod.rs @@ -0,0 +1,38 @@ +//! Iterative solvers that drive a [`SubSystem`](crate::subsystem::SubSystem)'s +//! residual toward zero by moving its free parameters. +//! +//! Ported from planegcs's three algorithms in `GCS.cpp` +//! (`System::solve_DL`/`solve_LM`/`solve_BFGS`), one per submodule. Common +//! to all of them: the C++ solves against its own scratch parameter copy +//! (`SubSystem::pvals`, populated by `redirectParams()`) and leaves the +//! *real* parameters untouched until a caller separately calls +//! `applySolution()` after checking the return status. This port has no +//! such scratch copy (see `subsystem.rs`'s module doc) — `SubSystem:: +//! set_params` always writes straight into the live [`ParamStore` +//! ](crate::util::ParamStore) — so each solver here takes that snapshot +//! itself, via [`ParamStore::redirect`](crate::util::ParamStore::redirect) +//! before iterating, and either leaves the store holding the converged +//! values (success) or calls +//! [`ParamStore::revert`](crate::util::ParamStore::revert) to undo every +//! trial step (failure) — the same commit-on-success/discard-on-failure +//! contract, just self-contained instead of split across two calls. + +pub mod bfgs; +pub mod dogleg; +pub mod lm; + +/// Mirrors planegcs's `SolveStatus` (`GCS.h`). Dogleg and LM only ever +/// produce two of its three C++ values — every non-1 stop code (max- +/// iterations, no-progress, diverging, NaN) collapses to `Failed`, matching +/// their own `return (stop == 1) ? Success : Failed;`. BFGS is the one +/// solver that distinguishes a genuine `Converged` (step size below +/// tolerance, but the residual itself isn't yet below `smallF`) from a full +/// `Success` — see its own return statement in `GCS.cpp` — so this keeps +/// that third variant rather than folding it into `Failed`, which would +/// misrepresent what is usually still a usable result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SolveStatus { + Success, + Converged, + Failed, +} diff --git a/crates/ocs_gcs/src/subsystem.rs b/crates/ocs_gcs/src/subsystem.rs new file mode 100644 index 000000000..a29c249b1 --- /dev/null +++ b/crates/ocs_gcs/src/subsystem.rs @@ -0,0 +1,268 @@ +//! Assembles a set of constraints' residual, Jacobian, and gradient against +//! a chosen set of free parameters. +//! +//! Ported from planegcs's `SubSystem.h`/`.cpp`, with one deliberate +//! simplification: the C++ `SubSystem` keeps its own working copy of +//! parameter values (`pvals`) and temporarily redirects each constraint's +//! `double*`s to point into it (`redirectParams`/`revertParams`), so solving +//! can be undone by reverting those pointers, with `applySolution()` copying +//! the working copy back at the end. Here, constraints always address +//! [`ParamStore`] directly through [`ParamId`]s (see `util.rs`), so there is +//! no separate working copy to redirect into or apply back — a solver rolls +//! back a bad iteration via [`ParamStore::redirect`]/[`ParamStore::revert`] +//! on the store itself instead. That drops `pvals`/`pmap`/`redirectParams`/ +//! `revertParams`/`applySolution`/`getParamMap` as having no counterpart +//! here, and likewise drops the reduction-map constructor variant (used in +//! the C++ to merge coincident-point parameters), deferred to whichever +//! later stage needs that specific optimization. + +use std::collections::HashSet; +use std::rc::Rc; + +use nalgebra::{DMatrix, DVector}; + +use crate::constraints::Constraint; +use crate::util::{ParamId, ParamStore}; + +pub struct SubSystem { + clist: Vec>, + /// The free parameters this subsystem solves for: the intersection of + /// the candidate `params` passed to [`SubSystem::new`] with the union of + /// every constraint's own parameters — mirrors the C++'s `tmpplist` + /// (`s1 ∩ s2`) becoming `plist` in the no-reduction-map path. + plist: Vec, +} + +impl SubSystem { + pub fn new(clist: Vec>, params: &[ParamId]) -> Self { + let candidates: HashSet = params.iter().copied().collect(); + let mut referenced: HashSet = HashSet::new(); + for constr in &clist { + referenced.extend(constr.params()); + } + // Preserve `params`' order, matching `std::set_intersection` over + // ordered sets in the C++ (plist's order is otherwise unobserved by + // any caller here, but a stable order keeps Jacobian columns and + // solver output reproducible run to run). + let plist: Vec = params.iter().copied().filter(|p| referenced.contains(p) && candidates.contains(p)).collect(); + + Self { clist, plist } + } + + pub fn p_size(&self) -> usize { + self.plist.len() + } + + pub fn c_size(&self) -> usize { + self.clist.len() + } + + pub fn plist(&self) -> &[ParamId] { + &self.plist + } + + /// This subsystem's constraints, in the same order [`calc_residual`]/ + /// [`calc_jacobi`]'s rows use — lets a caller (e.g. `diagnosis.rs`) map + /// a Jacobian row index back to the constraint it came from. + pub fn constraints(&self) -> &[Rc] { + &self.clist + } + + /// `0.5 * sum(constraint.error()^2)` — the scalar objective a solver + /// minimizes. + pub fn error(&self, store: &ParamStore) -> f64 { + let mut err = 0.0; + for constr in &self.clist { + let tmp = constr.error(store); + err += tmp * tmp; + } + 0.5 * err + } + + /// One residual entry per constraint, in `clist` order. + pub fn calc_residual(&self, store: &ParamStore) -> DVector { + DVector::from_iterator(self.c_size(), self.clist.iter().map(|c| c.error(store))) + } + + /// Jacobian of the residual w.r.t. `params`: `csize x params.len()`. + pub fn calc_jacobi_for(&self, store: &ParamStore, params: &[ParamId]) -> DMatrix { + let mut jacobi = DMatrix::zeros(self.c_size(), params.len()); + for (j, ¶m) in params.iter().enumerate() { + for (i, constr) in self.clist.iter().enumerate() { + jacobi[(i, j)] = constr.grad(store, param); + } + } + jacobi + } + + /// [`calc_jacobi_for`](Self::calc_jacobi_for) against this subsystem's + /// own [`plist`](Self::plist) — mirrors the C++'s no-argument + /// `calcJacobi(Eigen::MatrixXd&)` overload. + pub fn calc_jacobi(&self, store: &ParamStore) -> DMatrix { + self.calc_jacobi_for(store, &self.plist.clone()) + } + + /// Gradient of [`error`](Self::error) w.r.t. `params`: + /// `sum(constraint.error() * constraint.grad(param))` per parameter. + /// + /// The C++ restricts this sum to constraints adjacent to `param` via a + /// `p2c` map; here every constraint is checked for every parameter + /// instead, relying on [`Constraint::grad`]'s own membership check to + /// contribute `0.0` for unrelated pairs — same result, no adjacency + /// bookkeeping to maintain yet. Revisit if profiling ever shows this + /// O(csize * params.len()) scan mattering at real sketch sizes. + pub fn calc_grad_for(&self, store: &ParamStore, params: &[ParamId]) -> DVector { + let mut grad = DVector::zeros(params.len()); + for (j, ¶m) in params.iter().enumerate() { + for constr in &self.clist { + grad[j] += constr.error(store) * constr.grad(store, param); + } + } + grad + } + + pub fn calc_grad(&self, store: &ParamStore) -> DVector { + self.calc_grad_for(store, &self.plist.clone()) + } + + /// Largest step scale in `(0, lim]` every constraint accepts for a + /// proposed direction, keyed the same way as `plist`/`dir`. + pub fn max_step_for(&self, store: &ParamStore, params: &[ParamId], dir: &[f64], lim: f64) -> f64 { + assert_eq!(params.len(), dir.len()); + let dir_map: std::collections::HashMap = + params.iter().copied().zip(dir.iter().copied()).collect(); + let mut alpha = lim; + for constr in &self.clist { + alpha = constr.max_step(store, &dir_map, alpha); + } + alpha + } + + pub fn max_step(&self, store: &ParamStore, dir: &[f64], lim: f64) -> f64 { + self.max_step_for(store, &self.plist.clone(), dir, lim) + } + + /// Reads this subsystem's free parameters' current values from `store`, + /// in [`plist`](Self::plist) order. + pub fn get_params(&self, store: &ParamStore) -> DVector { + DVector::from_iterator(self.p_size(), self.plist.iter().map(|&p| store.get(p))) + } + + /// Writes `x` into `store` at this subsystem's free parameters, in + /// [`plist`](Self::plist) order. + pub fn set_params(&self, store: &mut ParamStore, x: &DVector) { + assert_eq!(x.len(), self.p_size()); + for (i, &p) in self.plist.iter().enumerate() { + store.set(p, x[i]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal test-only constraint: `error = a - b` (so `a == b` at the + /// zero). Keeps these tests independent of any concrete constraint from + /// a later port stage. + struct Difference { + a: ParamId, + b: ParamId, + } + + impl Constraint for Difference { + fn params(&self) -> Vec { + vec![self.a, self.b] + } + + fn error_value(&self, store: &ParamStore) -> f64 { + store.get(self.a) - store.get(self.b) + } + + fn grad_value(&self, _store: &ParamStore, param: ParamId) -> f64 { + if param == self.a { + 1.0 + } else if param == self.b { + -1.0 + } else { + 0.0 + } + } + } + + #[test] + fn plist_is_the_intersection_of_candidates_and_constraint_params() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, false); + let unrelated = store.add(3.0, false); + + let constr: Rc = Rc::new(Difference { a, b }); + let sub = SubSystem::new(vec![constr], &[a, b, unrelated]); + + assert_eq!(sub.plist(), &[a, b]); + assert_eq!(sub.p_size(), 2); + assert_eq!(sub.c_size(), 1); + } + + #[test] + fn error_and_residual_reflect_the_constraint_error() { + let mut store = ParamStore::new(); + let a = store.add(5.0, false); + let b = store.add(2.0, false); + let constr: Rc = Rc::new(Difference { a, b }); + let sub = SubSystem::new(vec![constr], &[a, b]); + + assert_eq!(sub.calc_residual(&store)[0], 3.0); + assert_eq!(sub.error(&store), 0.5 * 3.0 * 3.0); + } + + #[test] + fn jacobi_and_grad_match_the_hand_derived_formulas() { + let mut store = ParamStore::new(); + let a = store.add(5.0, false); + let b = store.add(2.0, false); + let constr: Rc = Rc::new(Difference { a, b }); + let sub = SubSystem::new(vec![constr], &[a, b]); + + let jacobi = sub.calc_jacobi(&store); + assert_eq!(jacobi.nrows(), 1); + assert_eq!(jacobi.ncols(), 2); + assert_eq!(jacobi[(0, 0)], 1.0); + assert_eq!(jacobi[(0, 1)], -1.0); + + // grad[j] = error * d(error)/d(param_j) = 3.0 * (+-1.0) + let grad = sub.calc_grad(&store); + assert_eq!(grad[0], 3.0); + assert_eq!(grad[1], -3.0); + } + + #[test] + fn get_and_set_params_round_trip_through_the_store() { + let mut store = ParamStore::new(); + let a = store.add(5.0, false); + let b = store.add(2.0, false); + let constr: Rc = Rc::new(Difference { a, b }); + let sub = SubSystem::new(vec![constr], &[a, b]); + + let x = sub.get_params(&store); + assert_eq!(x[0], 5.0); + assert_eq!(x[1], 2.0); + + sub.set_params(&mut store, &DVector::from_vec(vec![10.0, 4.0])); + assert_eq!(store.get(a), 10.0); + assert_eq!(store.get(b), 4.0); + } + + #[test] + fn max_step_defaults_to_the_limit_when_no_constraint_restricts_it() { + let mut store = ParamStore::new(); + let a = store.add(5.0, false); + let b = store.add(2.0, false); + let constr: Rc = Rc::new(Difference { a, b }); + let sub = SubSystem::new(vec![constr], &[a, b]); + + let alpha = sub.max_step(&store, &[1.0, 1.0], 42.0); + assert_eq!(alpha, 42.0); + } +} diff --git a/crates/ocs_gcs/src/system.rs b/crates/ocs_gcs/src/system.rs new file mode 100644 index 000000000..c9b9b42bb --- /dev/null +++ b/crates/ocs_gcs/src/system.rs @@ -0,0 +1,155 @@ +//! The top-level solver system: owns the parameter store and the full +//! constraint list, and partitions them into independent [`SubSystem`]s. +//! +//! Ported from planegcs's `GCS::System` (`GCS.h`/`.cpp`), scoped to what +//! this port stage needs: parameter/constraint bookkeeping and partitioning. +//! The C++'s per-constraint-type `addConstraintXxx(...)` factory methods +//! aren't ported as named wrappers yet — `add_constraint` takes any already- +//! constructed [`Constraint`] (from `constraints::point_line`, +//! `::angle_distance`, `::circle_arc`) directly, which is equivalent for +//! everything actually ported so far, just less convenient at a call site +//! than a per-type method would be. The three solving algorithms +//! (`solve_BFGS`/`solve_LM`/`solve_DL`) and redundant/conflicting-constraint +//! diagnosis are separate, later port stages (`solvers/`, `diagnosis.rs`). + +use std::rc::Rc; + +use crate::constraints::Constraint; +use crate::graph; +use crate::subsystem::SubSystem; +use crate::util::{ParamId, ParamStore}; + +pub struct System { + store: ParamStore, + constraints: Vec>, +} + +impl System { + pub fn new() -> Self { + Self { store: ParamStore::new(), constraints: Vec::new() } + } + + pub fn store(&self) -> &ParamStore { + &self.store + } + + pub fn store_mut(&mut self) -> &mut ParamStore { + &mut self.store + } + + pub fn add_param(&mut self, value: f64, driven: bool) -> ParamId { + self.store.add(value, driven) + } + + pub fn add_constraint(&mut self, constraint: Rc) { + self.constraints.push(constraint); + } + + pub fn constraints(&self) -> &[Rc] { + &self.constraints + } + + /// Splits the current constraint list into independent [`SubSystem`]s — + /// mirrors `System::initSolution`'s partitioning step (`GCS.cpp`), + /// minus the equality-constraint parameter-reduction and + /// redundant/conflicting-constraint exclusion that step also does + /// (those belong to the later `diagnosis.rs` stage; every constraint + /// here is treated as driving and undiagnosed). + /// + /// A `SubSystem`'s free-parameter list is every one of its constraints' + /// referenced params *excluding* the ones marked `driven` in the store + /// (a dimensional constraint's target value, or any point the caller + /// added with `driven: true` to keep fixed) — the solver moves the + /// former to satisfy error = 0, and must never move the latter. Every + /// hand-built `SubSystem::new(..., candidates)` call elsewhere in this + /// port (and every one-shot bridge in the app crate) passes its own + /// explicit non-driven candidate list for exactly this reason; this is + /// `System`'s equivalent, computed automatically from the store instead + /// of by the caller. + pub fn partition(&self) -> Vec { + graph::partition_by_shared_params(&self.constraints) + .into_iter() + .map(|indices| { + let clist: Vec> = indices.iter().map(|&i| self.constraints[i].clone()).collect(); + let mut params: Vec = + clist.iter().flat_map(|c| c.params()).filter(|&p| !self.store.is_driven(p)).collect(); + params.sort(); + params.dedup(); + SubSystem::new(clist, ¶ms) + }) + .collect() + } +} + +impl Default for System { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::point_line::{Difference, P2PDistance}; + use crate::geo::Point; + + #[test] + fn a_two_point_distance_sketch_builds_partitions_and_round_trips() { + // Two independent constraints on disjoint geometry: a P2PDistance + // between two points, and an unrelated Difference — should land in + // two separate subsystems. + let mut sys = System::new(); + + let p1 = Point::new(sys.add_param(0.0, false), sys.add_param(0.0, false)); + let p2 = Point::new(sys.add_param(3.0, false), sys.add_param(4.0, false)); + let distance = sys.add_param(5.0, true); // driven: the target distance + sys.add_constraint(Rc::new(P2PDistance::new(p1, p2, distance))); + + let a = sys.add_param(1.0, false); + let b = sys.add_param(2.0, false); + let d = sys.add_param(1.0, true); + sys.add_constraint(Rc::new(Difference::new(a, b, d))); + + assert_eq!(sys.constraints().len(), 2); + + let mut subsystems = sys.partition(); + assert_eq!(subsystems.len(), 2, "the two constraints share no parameters"); + + // "Stub solve": no actual iterative solver yet (stage 8) — just + // confirm the assembled pipeline (partition -> residual/jacobi -> + // get/set params) round-trips correctly end to end. + subsystems.sort_by_key(|s| s.c_size().max(s.p_size())); + for sub in &subsystems { + let residual_before = sub.calc_residual(sys.store()); + let x = sub.get_params(sys.store()); + sub.set_params(sys.store_mut(), &x); // no-op round trip + let residual_after = sub.calc_residual(sys.store()); + assert_eq!(residual_before, residual_after); + } + + // The P2PDistance subsystem: 4 free params (p1.x, p1.y, p2.x, p2.y) + // — its `distance` target is `driven`, so `partition` must exclude + // it from the free list despite `P2PDistance::params()` returning + // it alongside the point coordinates. + let p2p_sub = subsystems.iter().find(|s| s.p_size() == 4).expect("the 4-param P2PDistance subsystem"); + // Points already satisfy distance=5 (3-4-5 triangle), so its + // residual should already be ~zero. + assert!(p2p_sub.error(sys.store()).abs() < 1e-9); + } + + #[test] + fn partition_excludes_driven_params_from_each_subsystems_free_list() { + let mut sys = System::new(); + let p1 = Point::new(sys.add_param(0.0, true), sys.add_param(0.0, true)); // fixed anchor + let p2 = Point::new(sys.add_param(1.0, false), sys.add_param(0.0, false)); // free, wrong distance + let distance = sys.add_param(5.0, true); // driven target + sys.add_constraint(Rc::new(P2PDistance::new(p1, p2, distance))); + + let subsystems = sys.partition(); + assert_eq!(subsystems.len(), 1); + // Only p2.x/p2.y are free — p1's coords and the distance target are + // all `driven`, so despite being referenced by `P2PDistance::params()` + // they must not appear in the subsystem's free-parameter list. + assert_eq!(subsystems[0].p_size(), 2); + } +} diff --git a/crates/ocs_gcs/src/util.rs b/crates/ocs_gcs/src/util.rs new file mode 100644 index 000000000..d42437c73 --- /dev/null +++ b/crates/ocs_gcs/src/util.rs @@ -0,0 +1,161 @@ +//! Parameter storage. +//! +//! planegcs represents every solver parameter as a raw `double*` aliased +//! between geometry storage and the solver's working copy, with +//! `redirectParams`/`revertParams` swapping what those pointers point at +//! during an iteration. Rust disallows that aliasing, so parameters are +//! plain indices (`ParamId`) into a [`ParamStore`], and "redirect" becomes +//! snapshotting the store's values before an iterative solve mutates them +//! in place, with "revert" restoring that snapshot on failure. + +/// An index into a [`ParamStore`]. Cheap to copy, holds no value itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ParamId(u32); + +/// The solver's parameter values, addressed by [`ParamId`]. +/// +/// A parameter marked `driven` is a fixed input (e.g. a dimension value) +/// the solver must read but never write. +#[derive(Debug, Clone, Default)] +pub struct ParamStore { + values: Vec, + driven: Vec, +} + +/// A saved copy of a [`ParamStore`]'s values, for [`ParamStore::revert`]. +/// +/// Only valid against the store it was taken from; reverting into a store +/// with a different parameter count is a programmer error and panics. +#[derive(Debug, Clone)] +pub struct Snapshot(Vec); + +impl ParamStore { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Adds a parameter and returns the id to address it by. + pub fn add(&mut self, value: f64, driven: bool) -> ParamId { + let id = ParamId(self.values.len() as u32); + self.values.push(value); + self.driven.push(driven); + id + } + + pub fn get(&self, id: ParamId) -> f64 { + self.values[id.0 as usize] + } + + /// Overwrites a parameter's value regardless of its `driven` flag. + /// + /// The solver itself must not call this on a driven parameter — that + /// check belongs to the constraint/subsystem layer, which knows which + /// parameters it is allowed to move. This method stays unconditional so + /// setting up or re-driving a sketch (the one place driven values do + /// change) does not need a separate code path. + pub fn set(&mut self, id: ParamId, value: f64) { + self.values[id.0 as usize] = value; + } + + pub fn is_driven(&self, id: ParamId) -> bool { + self.driven[id.0 as usize] + } + + pub fn set_driven(&mut self, id: ParamId, driven: bool) { + self.driven[id.0 as usize] = driven; + } + + /// Captures the current values so a failed solve can [`revert`](Self::revert). + pub fn redirect(&self) -> Snapshot { + Snapshot(self.values.clone()) + } + + /// Restores values captured by [`redirect`](Self::redirect). + /// + /// Panics if `snapshot` was not taken from this store (parameter count + /// mismatch) — that pairing is a solver-internal invariant, not + /// something callers should recover from. + pub fn revert(&mut self, snapshot: &Snapshot) { + assert_eq!( + self.values.len(), + snapshot.0.len(), + "snapshot taken from a store with a different parameter count" + ); + self.values.copy_from_slice(&snapshot.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_get_set_round_trip() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, true); + assert_eq!(store.get(a), 1.0); + assert_eq!(store.get(b), 2.0); + assert!(!store.is_driven(a)); + assert!(store.is_driven(b)); + + store.set(a, 5.0); + assert_eq!(store.get(a), 5.0); + } + + #[test] + fn redirect_then_revert_restores_original_values() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + let b = store.add(2.0, false); + + let snapshot = store.redirect(); + store.set(a, 100.0); + store.set(b, 200.0); + assert_eq!(store.get(a), 100.0); + assert_eq!(store.get(b), 200.0); + + store.revert(&snapshot); + assert_eq!(store.get(a), 1.0); + assert_eq!(store.get(b), 2.0); + } + + #[test] + fn revert_after_no_mutation_is_a_no_op() { + let mut store = ParamStore::new(); + let a = store.add(3.5, false); + let snapshot = store.redirect(); + store.revert(&snapshot); + assert_eq!(store.get(a), 3.5); + } + + #[test] + #[should_panic(expected = "different parameter count")] + fn revert_with_mismatched_snapshot_panics() { + let mut store = ParamStore::new(); + store.add(1.0, false); + let snapshot = store.redirect(); + + let mut other = ParamStore::new(); + other.add(1.0, false); + other.add(2.0, false); + other.revert(&snapshot); + } + + #[test] + fn set_driven_can_toggle_the_flag() { + let mut store = ParamStore::new(); + let a = store.add(1.0, false); + assert!(!store.is_driven(a)); + store.set_driven(a, true); + assert!(store.is_driven(a)); + } +} diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 000000000..f36cbb6b4 --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,148 @@ +# Backlog — deferred items, known gaps, and scope-downs + +A consolidated list of things noticed but deliberately not fixed/built, so +they aren't lost between sessions. Pulled from `docs/ +parametric_system_design.md`'s own "open questions" log, the `ocs_gcs` port +plan, and things found while doing unrelated work this session. Not a bug +tracker for the whole app — scoped to what surfaced during the parametric +constraint system project and its surrounding investigation. + +## Parametric constraint system — deliberate scope-downs (documented, not accidental) + +1. **Stage 9 undo is two presses, not one.** Adding a persistent constraint + and the geometry move it triggers are two separate undo/redo entries + (`HistorySnapshot::SketchConstraints` pushed adjacent to, not merged into, + the geometry `Delta`). The design doc's recommended §5.1(b) "live + XRecord" approach would fix this but depends on stage 4 (save/load), + which itself was scoped down (next item) — revisit both together if this + is ever worth fixing. +2. **Stage 4 does not resolve on load.** Opening a file with saved + constraints restores them but does not immediately re-solve — the DOF + badge and any conflict state only appear after the next edit that touches + an affected scope. Deliberate: resolving on load risked desyncing from + the `DerivedCaches` bundle `on_file_opened` installs right after, without + budget this session to verify that's safe. +3. **Stage 11's conflict resolver is a one-at-a-time pill, not the full + SketchXpert-style panel.** Clicking "⚠ n conflicting" removes one flagged + constraint and re-solves; there's no named-candidate list or cyclable live + preview before committing (design doc §6.4 Phase C). This is the single + largest UX gap remaining in the constraint system relative to the + original plan. + +## `ocs_gcs` solver — not ported + +- `SlopeAtBSplineKnot` (tangent continuity across a spline knot) — needs + knot-multiplicity reconstruction for a narrower use case than + `PointOnBSpline`, which is ported. Deferred, not silently dropped. +- `InternalAlignmentPoint2Ellipse` / `InternalAlignmentPoint2Hyperbola` — + FreeCAD-Sketcher-internal bookkeeping for *creating* conic geometry + interactively, not for constraining existing geometry. Lower priority. +- `EllipticalArcRangeToEndPoints` — dead code upstream (a `ConstraintType` + enum value with no implementing class anywhere in current planegcs + source), not a gap in this port. +- **Golden-fixture testing against the locally-installed FreeCAD.app** + (testing-strategy "Oracle A") was never wired up. Everything in `ocs_gcs` + is verified only against Oracle B (closed-form + finite-difference). Still + a real asset sitting unused if higher-confidence validation is ever + wanted. +- **Performance at scale is untested.** The "only touched scopes rebuild" + locality argument (design doc §4.2) is architecturally sound but has no + profiling behind it on a large, heavily-constrained drawing. + +## Constraint types not exposed in the app's UI (even though some primitives exist) + +`ConstraintKind` (`src/scene/sketch_constraints.rs`) currently has: +Coincident, Horizontal, Vertical, Parallel, Perpendicular, Equal, Distance, +Angle, Radius, Tangent. AutoCAD's own Geometric Constraints panel additionally +has **Fix**, **Concentric**, **Collinear**, **Symmetric**, **Smooth** — none +of these have a `ConstraintKind` variant here. Some are buildable from +already-ported `ocs_gcs` primitives (e.g. Concentric ≈ `C2CDistance` at zero +distance) without new solver math; `Fix` would need a different mechanism +entirely (pinning a point's params non-free rather than adding a constraint +row) and doesn't fit the current `ConstraintKind` model at all. + +## Newly discovered this session (not previously tracked anywhere) + +- **`CCONSTRAINT`, `TCONSTRAINT`, and their four siblings + (`HCONSTRAINT`/`VCONSTRAINT`/`PCONSTRAINT`/`QCONSTRAINT`/`ECONSTRAINT`) have + no `inventory::submit!(CommandRegistration)` registration.** Found while + generating `docs/command_reference.md`. All seven work correctly when + invoked (ribbon button or exact typed name) but don't appear in the + command-line's autocomplete or in the MCP server's `commands` listing, + unlike almost every other command in the app. Pre-existing across the + whole constraint-command family, not something this session's Tangent/ + Coincident additions broke uniquely — just never noticed before. Cheap fix + (one `inventory::submit!` block) whenever someone wants to spend five + minutes on it. +- **No live link from a sketch to 3D solid features.** + `SolidHistorySweep`/`Revolve`/etc. (`src/scene/model/solid_history.rs`) + hold `sweep_entity: Option` — a geometry copy snapshotted + at creation time, not a `Handle` back into the sketch. Dragging or + re-solving a sketch that was previously extruded/revolved/swept does not + update the resulting solid. This is the real "feature-history tree" gap + discussed when scoping FreeCAD/AutoCAD parity — a substantially bigger + project than the named-parameters work (see `docs/ + named_parameters_design.md` §3), not something to fold into it by + accident. +- **No named-parameter/expression sheet** (AutoCAD's Parameters Manager / + FreeCAD's Spreadsheet equivalent). This is the next feature queued up — + see `docs/named_parameters_design.md` for the handoff context. +- **No end-user manual exists anywhere in the repo.** Checked `docs/` + thoroughly — everything there is developer-facing (plugin architecture, + this parametric design doc, tessellation notes, native-vs-web diffing, + release notes, automation scripts) plus localized top-level README + translations. No in-app Help link points anywhere either. Not started; + would be a real, separate writing effort if wanted. +- **No theme install/import/export mechanism.** Theming is entirely in-app: + pick one of iced's built-in themes, or hand-tune one "Custom" slot via six + hex-color inputs (`src/ui/window/options.rs`, `UiThemePalette` in + `src/app/config.rs`). No theme file format, no way to author and share a + theme, only one custom palette remembered at a time. +- **No touch/multi-touch input support**, relevant to the wasm build running + on a tablet (e.g. iPad). Grepped the whole codebase for touch/gesture + handling — none exists; all input assumes mouse+keyboard, relying on the + browser's synthetic mouse-event emulation for taps. No pinch-zoom, no + two-finger pan, and OSNAP/grip-drag precision (already tight even with a + mouse — see the Coincident epsilon note below) would be materially worse + with a fingertip. + +## Testing/tooling notes for future live-verification sessions + +- **Coincident's success path could not be reliably live-verified via + synthetic mouse clicks** — its resolver epsilon (`COINCIDENT_EPSILON_SQ = + 1.0e-12`, ~1e-6 world units, reused from stage 8's `infer_coincident_refs`) + is tighter than this session's `left_click`/OSNAP automation could + reliably hit, even with Endpoint snap enabled. Concluded to be an + environment limitation, not a code defect — automated tests cover the + resolver logic directly — but worth knowing before assuming a future + automated click-through will succeed where this session's didn't. +- **`open_application`/`request_access` always resolve to the + `/Applications`-installed app bundle**, regardless of which binary was + most recently built. To live-test a code change, build a release binary + and swap it into a **scratch copy** of the installed bundle (e.g. + `/tmp/ocs_livetest/OpenCADStudio.app`), keeping the original small + launcher (`CFBundleExecutable = "OpenCADStudio"`) intact, then + `codesign --force --deep -s -` to re-sign ad-hoc before `open`-ing it. + This reuses the real bundle identity (`io.github.HakanSeven12.OpenCadStudio`) + so access requests correctly target it. A standalone bundle with a fresh + bundle id gets silently `user_denied` — not a viable shortcut. The debug + binary run bare via `nohup` never presented a controllable window either + — use a release build. +- **`target/debug` can balloon fast from repeated live-testing rebuilds** — + hit 35GB and caused a real "No space left on device" build failure this + session. `cargo clean` reclaimed 64GB. Worth a proactive `cargo clean` (or + checking `df`) before a long live-testing stretch, not just after it + breaks. +- Synthetic input reliability, generally: Escape frequently failed to cancel + an active command (use Return instead); LINE clicks sometimes produced + stray zero-length duplicate entities; selection clicks often missed thin + line bodies or landed on a midpoint-edit-box zone (use a crossing-window + drag-select instead of precise body clicks). + +## Legal / process + +- **LGPL-2.1-or-later review for `ocs_gcs` is still open** (original plan + §6). Non-blocking for continued development, but must happen before any + public release containing it — separate-relinkable-module posture vs. + broader relicensing, notice/attribution obligations under GPLv3 + distribution. diff --git a/docs/command_reference.md b/docs/command_reference.md new file mode 100644 index 000000000..47a983605 --- /dev/null +++ b/docs/command_reference.md @@ -0,0 +1,1443 @@ +# OpenCADStudio command reference + +Generated by statically parsing every `impl CadCommand` block and its `inventory::submit!(CommandRegistration { .. })` registration in `src/` — the same registry the in-app command-line autocomplete and the MCP server's `ocs_read op:"commands"` draw from (see [docs/automation/README.md](automation/README.md)). No build or live session was needed to produce this list. + +- **531** registered command names (aliases included, e.g. `3O` alongside `3DORBIT`). +- **126** have an extractable initial prompt string; the rest are one-shot actions, dialog launchers, or toggles with no interactive prompt. +- **107** are multi-step commands — their full prompt sequence is in the [Multi-step command detail](#multi-step-command-detail) section below. + +**Known gap found while generating this list**: the six sketch-constraint commands (`HCONSTRAINT`, `VCONSTRAINT`, `PCONSTRAINT`, `QCONSTRAINT`, `ECONSTRAINT`, `TCONSTRAINT`) and `CCONSTRAINT` are dispatched directly by name in `src/app/commands/draw.rs` and (for `CCONSTRAINT`) do implement `CadCommand`, but none of them carry an `inventory::submit!` registration — so unlike almost every other command in the app, they don't appear in command-line autocomplete or in the MCP `commands` listing. This predates this list (all of the constraint family shares it, not just the two added most recently), so it's noted here rather than silently worked around. + +## Contents + +- [App — misc dispatch](#app-—-misc-dispatch) (348) +- [Dialogs / Managers](#dialogs-managers) (2) +- [Draw](#draw) (66) +- [Insert / Blocks / Solids / Attributes](#insert-blocks-solids-attributes) (7) +- [Modify](#modify) (25) +- [modules/annotate](#modulesannotate) (32) +- [modules/draw/clipboard](#modulesdrawclipboard) (4) +- [modules/draw/groups](#modulesdrawgroups) (1) +- [modules/draw/inquiry](#modulesdrawinquiry) (7) +- [modules/draw/layers](#modulesdrawlayers) (2) +- [modules/draw/properties](#modulesdrawproperties) (1) +- [modules/draw/select.rs](#modulesdrawselect.rs) (21) +- [modules/layout](#moduleslayout) (2) +- [modules/model](#modulesmodel) (11) +- [modules/view](#modulesview) (2) +- [Multi-step command detail](#multi-step-command-detail) + +## App — misc dispatch + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `3DALIGN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `3DMIRROR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `3DMOVE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `3DORBIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `3DROTATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `3O` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ABOUT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ADC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ADCENTER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ADDSELECTED` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ADJUST` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ALIASEDIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ALIGN3D` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ANGBASE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ANGDIR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ANNOALLVISIBLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ANNOAUTOSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ANNOSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ANNOUPDATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ARCHIVE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ARCTEXT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTDIA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTDISP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTEXT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTMAN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTREQ` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ATTSYNC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `AUDIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `AUNITS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `AUPREC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BACKGROUND` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BASE` | BASE Specify base point: | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BATTMAN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BLIPMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BLOCKPALETTE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BLOCKSPALETTE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BMAKE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `BYLAYER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CAL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CANNOSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CASCADE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CDIMSTY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CECOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CELTSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CHANGELOG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CHPROP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CLAYER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CLEANSCREEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CLEAR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CLIPROMPTLINES` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CLOSE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CLR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CMLJUST` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CMLSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CMLSTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COLORSCHEME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COLORTHEME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COLOUR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COMMANDLINEFADETIME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CONTENTBROWSER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CONVTOSURFACE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COORDS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COPYBASE` | COPYBASE Specify base point: | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `COUNT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CROSSINGAREACOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CUI` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CUIEXPORT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CUIIMPORT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CUILOAD` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CURSORSIZE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `CURSORTYPE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DATAEXTRACTION` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DATALINK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DATALINKUPDATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DBLIST` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DDCOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DDIM` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DDUNITS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DELOBJ` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DESELALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DESELECT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DIMASO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DIMASSOC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DIMJOG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DIMJOGGED` | DIMJOGGED Enter dimension text (blank = measured value): *(+5 more steps)* | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DIMSHO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DIMSTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DISPSILH` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DJO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DONATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DRAGMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DRAWORDER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DSETTINGS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DWGPROP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DWGPROPS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `DWGUNITS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EATTEXT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ELEVATION` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ETRANSMIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EXIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EXPORT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EXPORTPDF` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EXPORTSTEP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EXPORTSTL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `EXTRIM` | EXTRIM Select cutting boundary: *(+1 more step)* | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FI` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FILETAB` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FILTER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FIND` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FINDNONPURGEABLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FITSPLINE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FLATSHOT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FLATTEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FRAMES0` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FRAMES1` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `FRAMES2` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `GRID` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `GRIPCOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `GRIPHOT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `GRIPHOVER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `GRIPSIZE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HALOGAP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HATCHTOBACK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HB` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HELP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HI` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HIDE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HIDEOBJECTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HORIZONTAL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `HYPERLINK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `IM` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `IMAGE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `IMAGEATTACH` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `IMAGEFRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `IMPORTOBJ` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `INF` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `INSUNITS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `INTERFERE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ISODRAFT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ISOLATEOBJECTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ISOLINES` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ISOPLANE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `JUSTIFYTEXT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LANDXMLIMPORT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYDEL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYERS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYERSTATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYISO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYMRG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYON` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYOUTMANAGER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYOUTPANEL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYOUTTAB` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYTHW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYTRANS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LAYUNISO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LI` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LIMCHECK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LINETYPE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LIST` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LMAN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LTSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LUNITS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LUPREC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `LWDISPLAY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MASSPROP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MAXACTVP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MIRROR3D` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MIRRTEXT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MLEADERSTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MLSTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `MSPACE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `NAVVCUBE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `NCOPY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `NCOPYALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `NEW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OBJECTSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OBJIMPORT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OOPS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OPEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OPTIONS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ORTHO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OSMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OSNAP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `OVERKILL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `P` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PAGESETUP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PAN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PDFFRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PERF` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PERSP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PICKADD` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PICKBOX` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PICKDRAG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PICKSTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLAN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLIMCHECK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLINEGEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLOT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLOTSTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLOTSTYLEEDITOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PLOTSTYLEPANEL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `POINTCLOUDCLIPFRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `POLAR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `POLYSOLID` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PRESSPULL` | PRESSPULL Select object or bounded area (Enter to finish): *(+2 more steps)* | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PRINT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PRINTALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PROPERTIES` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PROPS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PSLTSCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PSPACE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PURGE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PYR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `PYRAMID` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QLEADER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QSAVE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QSELECT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QTEXTMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QUICKCALC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QUICKPRINT` | QUICKPRINT Select objects to quick-print: *(+1 more step)* | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QUICKPROPERTIES` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `QUIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REDO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REDRAW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REDRAWALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REGEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REGENALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REGENMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REGION` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `RENAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `REPORT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ROTATE3D` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SAVE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SAVEALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SAVEAS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SCALELISTEDIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SCALETEXT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SCR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SCRIPT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SECTION` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTALL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTIONAREA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTIONAREAOPACITY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTIONEFFECT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTIONEFFECTCOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTIONPREVIEW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELECTSIMILAR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SELSIM` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SETBYLAYER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SETVAR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SHADEDGE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SHADEMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SHEETSET` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SHORTCUTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SKETCHINC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SKPOLY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SKTOLERANCE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SLICE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SNAP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SNAPANG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SORTENTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SPLFRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SPLINEFIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SPLINESEGS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SPLINETYPE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SSM` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `STEPOUT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `STLOUT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `STPOUT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `STYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `STYLESMANAGER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SURFTYPE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SURFU` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SURFV` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `SYNCPVIEWPORTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TABLESTYLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TCASE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TCOUNT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TEXTFILL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TEXTFIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TEXTMASK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TEXTQLTY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TEXTTOBACK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TEXTTOFRONT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `THICKEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `THICKNESS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TOOLPALETTES` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TRACEWID` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `TS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `U` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UCS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UCSICON` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UNDERLAY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UNDO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UNISOLATEOBJECTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `UNITS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `USERI` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `USERR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `USRTIMER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VERTICAL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VIEW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VISRETAIN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VISUALSTYLES` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VPJOIN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VPORTS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VPSYNC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VSCURRENT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `VW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WB` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WBLOCK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WEBVERSION` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WINDOWAREACOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WINDOWSAREACOLOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WIPEOUTFRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `WORLDVIEW` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XATTACH` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XCLIPFRAME` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XDATA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XOPEN` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XREF` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `XRELOAD` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ZOOM` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ZOOMFACTOR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ZOOMWHEEL` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | +| `ZS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/app/commands/mod.rs](src/app/commands/mod.rs) | + +## Dialogs / Managers + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `PLUGINMANAGER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/ui/window/plugin_manager.rs](src/ui/window/plugin_manager.rs) | +| `PLUGINS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/ui/window/plugin_manager.rs](src/ui/window/plugin_manager.rs) | + +## Draw + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `3DFACE` | 3DFACE Specify first point or [Invisible]: *(+3 more steps)* | [src/modules/draw/draw/face3d.rs](src/modules/draw/draw/face3d.rs) | +| `3DMESH` | 3DMESH Enter size of mesh in M direction (2-256): *(+2 more steps)* | [src/modules/draw/draw/mesh3d.rs](src/modules/draw/draw/mesh3d.rs) | +| `3DPOLY` | 3DPOLY Specify start point of polyline: *(+2 more steps)* | [src/modules/draw/draw/poly3d.rs](src/modules/draw/draw/poly3d.rs) | +| `ARC` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_3P` | ARC 3P Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_CONT` | ARC Continue Specify end point [Ctrl = flip direction]: | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_CSA` | ARC CSA Specify center: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_CSE` | ARC Specify center: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_CSL` | ARC CSL Specify center: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_SCA` | ARC SCA Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_SCE` | ARC SCE Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_SCL` | ARC SCL Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_SEA` | ARC SEA Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_SED` | ARC SED Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ARC_SER` | ARC SER Specify start point: *(+2 more steps)* | [src/modules/draw/draw/arc.rs](src/modules/draw/draw/arc.rs) | +| `ATTDEF` | ATTDEF Enter attribute tag (no spaces): *(+3 more steps)* | [src/modules/draw/draw/attdef.rs](src/modules/draw/draw/attdef.rs) | +| `BOUNDARY` | ⚠ No closed boundary found. *(+3 more steps)* | [src/modules/draw/draw/hatch.rs](src/modules/draw/draw/hatch.rs) | +| `CENTERDISASSOCIATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/centerline.rs](src/modules/draw/draw/centerline.rs) | +| `CENTERLINE` | CENTERLINE Select first line: *(+1 more step)* | [src/modules/draw/draw/centerline.rs](src/modules/draw/draw/centerline.rs) | +| `CENTERMARK` | CENTERMARK Select arc or circle : | [src/modules/draw/draw/dimcenter.rs](src/modules/draw/draw/dimcenter.rs) | +| `CENTERREASSOCIATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/centerline.rs](src/modules/draw/draw/centerline.rs) | +| `CENTERRESET` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/centerline.rs](src/modules/draw/draw/centerline.rs) | +| `CIRCLE` | CIRCLE Specify center point: *(+2 more steps)* | [src/modules/draw/draw/circle.rs](src/modules/draw/draw/circle.rs) | +| `CIRCLE_2P` | CIRCLE 2P Specify first end of diameter: *(+1 more step)* | [src/modules/draw/draw/circle.rs](src/modules/draw/draw/circle.rs) | +| `CIRCLE_3P` | CIRCLE 3P Specify first point: *(+2 more steps)* | [src/modules/draw/draw/circle.rs](src/modules/draw/draw/circle.rs) | +| `CIRCLE_CD` | CIRCLE CD Specify center point: *(+1 more step)* | [src/modules/draw/draw/circle.rs](src/modules/draw/draw/circle.rs) | +| `CIRCLE_TTR` | CIRCLE TTR Select first tangent object: *(+2 more steps)* | [src/modules/draw/draw/circle.rs](src/modules/draw/draw/circle.rs) | +| `CIRCLE_TTT` | CIRCLE TTT Select first tangent object: *(+2 more steps)* | [src/modules/draw/draw/circle.rs](src/modules/draw/draw/circle.rs) | +| `CONSTRUCTIONLINE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/ray.rs](src/modules/draw/draw/ray.rs) | +| `DCE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/dimcenter.rs](src/modules/draw/draw/dimcenter.rs) | +| `DDPTYPE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/point.rs](src/modules/draw/draw/point.rs) | +| `DIMCENTER` | DIMCENTER Select arc or circle: | [src/modules/draw/draw/dimcenter.rs](src/modules/draw/draw/dimcenter.rs) | +| `DONUT` | DONUT Specify inside diameter <0>: *(+4 more steps)* | [src/modules/draw/draw/donut.rs](src/modules/draw/draw/donut.rs) | +| `EDGE` | EDGE Select edge of 3D face to toggle visibility or [Display]: *(+2 more steps)* | [src/modules/draw/draw/face3d.rs](src/modules/draw/draw/face3d.rs) | +| `ELLIPSE` | ELLIPSE Specify center: *(+2 more steps)* | [src/modules/draw/draw/ellipse.rs](src/modules/draw/draw/ellipse.rs) | +| `ELLIPSE_ARC` | ELLIPSE ARC Specify center: *(+4 more steps)* | [src/modules/draw/draw/ellipse.rs](src/modules/draw/draw/ellipse.rs) | +| `ELLIPSE_AXIS` | ELLIPSE (Axis) Specify first endpoint of major axis: *(+2 more steps)* | [src/modules/draw/draw/ellipse.rs](src/modules/draw/draw/ellipse.rs) | +| `GRADIENT` | ⚠ No closed boundary found. *(+3 more steps)* | [src/modules/draw/draw/hatch.rs](src/modules/draw/draw/hatch.rs) | +| `HATCH` | ⚠ No closed boundary found. *(+5 more steps)* | [src/modules/draw/draw/hatch.rs](src/modules/draw/draw/hatch.rs) | +| `HATCHEDIT` | HATCHEDIT Select hatch: *(+1 more step)* | [src/modules/draw/draw/hatchedit.rs](src/modules/draw/draw/hatchedit.rs) | +| `HELIX` | HELIX Number of turns = {:.4} Twist = {}\nSpecify center point of base: *(+9 more steps)* | [src/modules/draw/draw/helix.rs](src/modules/draw/draw/helix.rs) | +| `LINE` | LINE Specify first point: *(+2 more steps)* | [src/modules/draw/draw/line.rs](src/modules/draw/draw/line.rs) | +| `MLINE` | MLINE Current settings: Justification = {}, Scale = {}, Style = {}\nSpecify start point or [Justification/Scale/STyle]: *(+5 more steps)* | [src/modules/draw/draw/mline.rs](src/modules/draw/draw/mline.rs) | +| `MULTIPOINT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/point.rs](src/modules/draw/draw/point.rs) | +| `PDMODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/point.rs](src/modules/draw/draw/point.rs) | +| `PDSIZE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/point.rs](src/modules/draw/draw/point.rs) | +| `PLINE` | Line *(+3 more steps)* | [src/modules/draw/draw/polyline.rs](src/modules/draw/draw/polyline.rs) | +| `POINT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/point.rs](src/modules/draw/draw/point.rs) | +| `POLY` | POLYGON Enter number of sides <{}>: *(+2 more steps)* | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `POLYGON` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `POLY_C` | POLYGON C Enter number of sides <{}>: *(+2 more steps)* | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `POLY_E` | POLYGON E Enter number of sides <%{n}>: *(+2 more steps)* | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `RAY` | RAY Specify start point: *(+1 more step)* | [src/modules/draw/draw/ray.rs](src/modules/draw/draw/ray.rs) | +| `RECT` | RECT Specify first corner: *(+15 more steps)* | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `RECTANG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `RECT_CEN` | RECT CEN Specify center point: *(+1 more step)* | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `RECT_ROT` | RECT ROT Specify first corner: *(+2 more steps)* | [src/modules/draw/draw/shapes.rs](src/modules/draw/draw/shapes.rs) | +| `REVCLOUD` | REVCLOUD First corner (%{style}, arc length %{length}): %{message} *(+12 more steps)* | [src/modules/draw/draw/revcloud.rs](src/modules/draw/draw/revcloud.rs) | +| `SKETCH` | SKETCH Object type [Line/Polyline/Spline]: *(+5 more steps)* | [src/modules/draw/draw/sketch.rs](src/modules/draw/draw/sketch.rs) | +| `SO` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/solid2d.rs](src/modules/draw/draw/solid2d.rs) | +| `SOLID` | SOLID Specify first point: *(+3 more steps)* | [src/modules/draw/draw/solid2d.rs](src/modules/draw/draw/solid2d.rs) | +| `SOLID2D` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/draw/solid2d.rs](src/modules/draw/draw/solid2d.rs) | +| `SPLINE` | SPLINE Specify first point: *(+1 more step)* | [src/modules/draw/draw/spline.rs](src/modules/draw/draw/spline.rs) | +| `TRACE` | TRACE Specify trace width <%{w}>: *(+3 more steps)* | [src/modules/draw/draw/trace.rs](src/modules/draw/draw/trace.rs) | +| `WIPEOUT` | WIPEOUT Specify first point or [Frames/Polyline] : *(+6 more steps)* | [src/modules/draw/draw/wipeout.rs](src/modules/draw/draw/wipeout.rs) | +| `XLINE` | XLINE Specify a point: *(+1 more step)* | [src/modules/draw/draw/ray.rs](src/modules/draw/draw/ray.rs) | + +## Insert / Blocks / Solids / Attributes + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `EXTRUDE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/insert/solid3d_cmds.rs](src/modules/insert/solid3d_cmds.rs) | +| `INSERT` | INSERT Enter block name: *(+9 more steps)* | [src/modules/insert/insert_block.rs](src/modules/insert/insert_block.rs) | +| `LOFT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/insert/solid3d_cmds.rs](src/modules/insert/solid3d_cmds.rs) | +| `MINSERT` | MINSERT Enter block name: *(+9 more steps)* | [src/modules/insert/minsert.rs](src/modules/insert/minsert.rs) | +| `PDFATTACH` | PDFATTACH Specify insertion point: | [src/modules/insert/pdf_attach.rs](src/modules/insert/pdf_attach.rs) | +| `REVOLVE` | REVOLVE Select objects to revolve or [Mode] (Enter to finish): *(+7 more steps)* | [src/modules/insert/solid3d_cmds.rs](src/modules/insert/solid3d_cmds.rs) | +| `SWEEP` | SWEEP Select objects to sweep or [Mode] (Enter to finish): *(+11 more steps)* | [src/modules/insert/solid3d_cmds.rs](src/modules/insert/solid3d_cmds.rs) | + +## Modify + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `3DARRAY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/modify/array.rs](src/modules/draw/modify/array.rs) | +| `ALIGN` | ALIGN Select objects (%{count} selected, Enter when done): *(+5 more steps)* | [src/modules/draw/modify/align.rs](src/modules/draw/modify/align.rs) | +| `ARRAY3D` | ARRAY3D Enter row count: *(+5 more steps)* | [src/modules/draw/modify/array.rs](src/modules/draw/modify/array.rs) | +| `ATTEDIT` | ATTEDIT Select block with attributes: | [src/modules/draw/modify/attedit.rs](src/modules/draw/modify/attedit.rs) | +| `BEDIT` | BEDIT Select block reference to edit: | [src/modules/draw/modify/block_edit.rs](src/modules/draw/modify/block_edit.rs) | +| `BEDIT_DISCARD` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/modify/block_edit.rs](src/modules/draw/modify/block_edit.rs) | +| `BEDIT_SAVE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/modify/block_edit.rs](src/modules/draw/modify/block_edit.rs) | +| `BLE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/modify/blend.rs](src/modules/draw/modify/blend.rs) | +| `BLEND` | BLEND Select first open curve [Continuity=%{c}]: *(+2 more steps)* | [src/modules/draw/modify/blend.rs](src/modules/draw/modify/blend.rs) | +| `BREAK` | BREAK Select object: *(+2 more steps)* | [src/modules/draw/modify/break_cmd.rs](src/modules/draw/modify/break_cmd.rs) | +| `BREAKATPOINT` | BREAKATPOINT Select object: *(+1 more step)* | [src/modules/draw/modify/break_cmd.rs](src/modules/draw/modify/break_cmd.rs) | +| `CHAMFER` | CHAMFER Select first line [D1=%{d1} D2=%{d2}]: *(+4 more steps)* | [src/modules/draw/modify/fillet.rs](src/modules/draw/modify/fillet.rs) | +| `EXTEND` | [Edge: Extend] *(+6 more steps)* | [src/modules/draw/modify/trim.rs](src/modules/draw/modify/trim.rs) | +| `FILLET` | FILLET Select first object (Line/Arc/LwPolyline) [R={:.4}]: *(+2 more steps)* | [src/modules/draw/modify/fillet.rs](src/modules/draw/modify/fillet.rs) | +| `JOIN` | JOIN Select objects to join (%{count} selected, Enter to apply): | [src/modules/draw/modify/join.rs](src/modules/draw/modify/join.rs) | +| `LENGTHEN` | LENGTHEN Select object: *(+1 more step)* | [src/modules/draw/modify/lengthen.rs](src/modules/draw/modify/lengthen.rs) | +| `MLEDIT` | MLEDIT Choose an edit tool: *(+4 more steps)* | [src/modules/draw/modify/mledit.rs](src/modules/draw/modify/mledit.rs) | +| `OFFSET` | OFFSET Specify offset distance or first reference point [Through] <%{d}>: *(+7 more steps)* | [src/modules/draw/modify/offset.rs](src/modules/draw/modify/offset.rs) | +| `PEDIT` | PEDIT Select polyline (or a line/arc to convert): *(+6 more steps)* | [src/modules/draw/modify/pedit.rs](src/modules/draw/modify/pedit.rs) | +| `REFCLOSE` | REFCLOSE [Save/Discard] : | [src/modules/draw/modify/refedit.rs](src/modules/draw/modify/refedit.rs) | +| `REFEDIT` | REFEDIT Select block reference to edit: | [src/modules/draw/modify/refedit.rs](src/modules/draw/modify/refedit.rs) | +| `REVERSE` | REVERSE Select line, polyline or spline to reverse: | [src/modules/draw/modify/reverse.rs](src/modules/draw/modify/reverse.rs) | +| `SPLINEDIT` | SPLINEDIT Select spline: *(+1 more step)* | [src/modules/draw/modify/splinedit.rs](src/modules/draw/modify/splinedit.rs) | +| `TORIENT` | TORIENT New absolute rotation : *(+1 more step)* | [src/modules/draw/modify/torient.rs](src/modules/draw/modify/torient.rs) | +| `TRIM` | [Edge: Extend] *(+6 more steps)* | [src/modules/draw/modify/trim.rs](src/modules/draw/modify/trim.rs) | + +## modules/annotate + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `DDEDIT` | DDEDIT Select text entity: | [src/modules/annotate/ddedit.rs](src/modules/annotate/ddedit.rs) | +| `DIMALIGNED` | DIMALIGNED Enter formatted dimension text (blank = measured value): *(+6 more steps)* | [src/modules/annotate/aligned_dim.rs](src/modules/annotate/aligned_dim.rs) | +| `DIMANGULAR` | DIMANGULAR Specify quadrant: *(+11 more steps)* | [src/modules/annotate/angular_dim.rs](src/modules/annotate/angular_dim.rs) | +| `DIMARC` | DIMARC Enter dimension text (blank = measured value): *(+5 more steps)* | [src/modules/annotate/arc_length_dim.rs](src/modules/annotate/arc_length_dim.rs) | +| `DIMBASELINE` | DIMBASELINE Select base dimension: *(+2 more steps)* | [src/modules/annotate/dim_baseline.rs](src/modules/annotate/dim_baseline.rs) | +| `DIMBREAK` | DIMBREAK Select dimension to break: *(+1 more step)* | [src/modules/annotate/dimbreak.rs](src/modules/annotate/dimbreak.rs) | +| `DIMCONTINUE` | DIMCONTINUE Select continued dimension: *(+2 more steps)* | [src/modules/annotate/dim_continue.rs](src/modules/annotate/dim_continue.rs) | +| `DIMDIAMETER` | DIMDIAMETER Enter dimension text (blank = measured value): *(+3 more steps)* | [src/modules/annotate/diameter_dim.rs](src/modules/annotate/diameter_dim.rs) | +| `DIMEDIT` | DIMEDIT Select dimension: *(+1 more step)* | [src/modules/annotate/dimedit.rs](src/modules/annotate/dimedit.rs) | +| `DIMJOGLINE` | DIMJOGLINE Select linear or aligned dimension: *(+1 more step)* | [src/modules/annotate/dimjogline.rs](src/modules/annotate/dimjogline.rs) | +| `DIMLINEAR` | DIMLINEAR Enter formatted dimension text (blank = measured value): *(+7 more steps)* | [src/modules/annotate/linear_dim.rs](src/modules/annotate/linear_dim.rs) | +| `DIMORDINATE` | DIMORDINATE Enter dimension text (blank = measured value): *(+3 more steps)* | [src/modules/annotate/ordinate_dim.rs](src/modules/annotate/ordinate_dim.rs) | +| `DIMRADIUS` | DIMRADIUS Enter dimension text (blank = measured value): *(+3 more steps)* | [src/modules/annotate/radius_dim.rs](src/modules/annotate/radius_dim.rs) | +| `DIMSPACE` | DIMSPACE Select base dimension: *(+2 more steps)* | [src/modules/annotate/dimspace.rs](src/modules/annotate/dimspace.rs) | +| `DIMTED` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/annotate/dimtedit.rs](src/modules/annotate/dimtedit.rs) | +| `DIMTEDIT` | DIMTEDIT Select dimension: *(+1 more step)* | [src/modules/annotate/dimtedit.rs](src/modules/annotate/dimtedit.rs) | +| `DSPACE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/annotate/dimspace.rs](src/modules/annotate/dimspace.rs) | +| `LEADER` | LEADER Specify arrowhead point: *(+1 more step)* | [src/modules/annotate/leader_cmd.rs](src/modules/annotate/leader_cmd.rs) | +| `MLEADER` | MLEADER Options [Leader type/Landing/Content type/Max points/First angle/Second angle/Layer/Exit]: *(+16 more steps)* | [src/modules/annotate/mleader_cmd.rs](src/modules/annotate/mleader_cmd.rs) | +| `MLEADERADD` | MLEADERADD Select a multileader: *(+2 more steps)* | [src/modules/annotate/mleader_edit.rs](src/modules/annotate/mleader_edit.rs) | +| `MLEADERALIGN` | MLEADERALIGN Select multileaders to align (Enter when done): *(+2 more steps)* | [src/modules/annotate/mleader_edit.rs](src/modules/annotate/mleader_edit.rs) | +| `MLEADERCOLLECT` | MLEADERCOLLECT Select multileaders to collect (Enter when done): *(+1 more step)* | [src/modules/annotate/mleader_edit.rs](src/modules/annotate/mleader_edit.rs) | +| `MLEADERREMOVE` | MLEADERREMOVE Select a multileader: *(+1 more step)* | [src/modules/annotate/mleader_edit.rs](src/modules/annotate/mleader_edit.rs) | +| `MTEXT` | MTEXT Specify first corner: *(+11 more steps)* | [src/modules/annotate/mtext.rs](src/modules/annotate/mtext.rs) | +| `QDIM` | QDIM Select geometry to dimension (Enter when done): *(+4 more steps)* | [src/modules/annotate/qdim.rs](src/modules/annotate/qdim.rs) | +| `TABLE` | TABLE Enter number of columns [%{cols}]: *(+9 more steps)* | [src/modules/annotate/table_cmd.rs](src/modules/annotate/table_cmd.rs) | +| `TABLEDIT` | TABLEDIT Select a table cell: | [src/modules/annotate/table_cmd.rs](src/modules/annotate/table_cmd.rs) | +| `TEDIT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/annotate/textedit.rs](src/modules/annotate/textedit.rs) | +| `TEXT` | TEXT Current style: {}, Height: {}, Annotative: {} *(+9 more steps)* | [src/modules/annotate/text.rs](src/modules/annotate/text.rs) | +| `TEXTEDIT` | TEXTEDIT Select an annotation object or [Undo Mode]: *(+5 more steps)* | [src/modules/annotate/textedit.rs](src/modules/annotate/textedit.rs) | +| `TEXTEDITMODE` | Requires 0 OR 1 OR MULTIPLE OR SINGLE. *(+1 more step)* | [src/modules/annotate/textedit.rs](src/modules/annotate/textedit.rs) | +| `TOLERANCE` | TOLERANCE Specify insertion point: | [src/modules/annotate/tolerance_cmd.rs](src/modules/annotate/tolerance_cmd.rs) | + +## modules/draw/clipboard + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `PASTE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/clipboard/paste.rs](src/modules/draw/clipboard/paste.rs) | +| `PASTEBLOCK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/clipboard/paste.rs](src/modules/draw/clipboard/paste.rs) | +| `PASTECLIP` | PASTECLIP Pick insertion point: | [src/modules/draw/clipboard/paste.rs](src/modules/draw/clipboard/paste.rs) | +| `PASTEORIG` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/clipboard/paste.rs](src/modules/draw/clipboard/paste.rs) | + +## modules/draw/groups + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `UNGROUP` | UNGROUP Select grouped objects: | [src/modules/draw/groups/ungroup.rs](src/modules/draw/groups/ungroup.rs) | + +## modules/draw/inquiry + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `AREA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/inquiry/area.rs](src/modules/draw/inquiry/area.rs) | +| `DIST` | DIST Specify first point: *(+1 more step)* | [src/modules/draw/inquiry/dist.rs](src/modules/draw/inquiry/dist.rs) | +| `DIVIDE` | DIVIDE Select object to divide: *(+1 more step)* | [src/modules/draw/inquiry/divide.rs](src/modules/draw/inquiry/divide.rs) | +| `ID` | ID Specify point: | [src/modules/draw/inquiry/id.rs](src/modules/draw/inquiry/id.rs) | +| `MEA` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/inquiry/measuregeom.rs](src/modules/draw/inquiry/measuregeom.rs) | +| `MEASURE` | MEASURE Select object to measure: *(+1 more step)* | [src/modules/draw/inquiry/divide.rs](src/modules/draw/inquiry/divide.rs) | +| `MEASUREGEOM` | MEASUREGEOM Enter an option [Distance/Radius/Angle/ARea]: *(+8 more steps)* | [src/modules/draw/inquiry/measuregeom.rs](src/modules/draw/inquiry/measuregeom.rs) | + +## modules/draw/layers + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `LAYMATCH` | LAYMATCH Select objects to change layer: *(+1 more step)* | [src/modules/draw/layers/match_layer.rs](src/modules/draw/layers/match_layer.rs) | +| `LAYMCH` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/layers/match_layer.rs](src/modules/draw/layers/match_layer.rs) | + +## modules/draw/properties + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `MATCHPROP` | MATCHPROP Select source object: *(+1 more step)* | [src/modules/draw/properties/match_prop.rs](src/modules/draw/properties/match_prop.rs) | + +## modules/draw/select.rs + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `ARRAY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `ARRAYPATH` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `ARRAYPOLAR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `ARRAYRECT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `BLOCK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `COPY` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `COPYCLIP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `CUTCLIP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `ERASE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `EXPLODE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `GROUP` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `LAYFRZ` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `LAYLCK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `LAYMCUR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `LAYOFF` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `LAYULK` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `MIRROR` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `MOVE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `ROTATE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `SCALE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | +| `STRETCH` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/draw/select.rs](src/modules/draw/select.rs) | + +## modules/layout + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `MVIEW` | MVIEW Specify corner of viewport or [Polygonal/Object/Fit/Insert view]: *(+11 more steps)* | [src/modules/layout/mview.rs](src/modules/layout/mview.rs) | +| `VPLAYER` | VPLAYER F = Freeze \| T = Thaw \| Enter = Exit | [src/modules/layout/vplayer.rs](src/modules/layout/vplayer.rs) | + +## modules/model + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `BOX` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/primitive_cmd.rs](src/modules/model/primitive_cmd.rs) | +| `CONE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/primitive_cmd.rs](src/modules/model/primitive_cmd.rs) | +| `CYLINDER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/primitive_cmd.rs](src/modules/model/primitive_cmd.rs) | +| `INTERSECT` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/boolean_cmd.rs](src/modules/model/boolean_cmd.rs) | +| `SOLIDCHAMFER` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/edge_cmd.rs](src/modules/model/edge_cmd.rs) | +| `SOLIDFILLET` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/edge_cmd.rs](src/modules/model/edge_cmd.rs) | +| `SPHERE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/primitive_cmd.rs](src/modules/model/primitive_cmd.rs) | +| `SUBTRACT` | SUBTRACT Select base solids, then press Enter: *(+1 more step)* | [src/modules/model/boolean_cmd.rs](src/modules/model/boolean_cmd.rs) | +| `TORUS` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/primitive_cmd.rs](src/modules/model/primitive_cmd.rs) | +| `UNION` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/boolean_cmd.rs](src/modules/model/boolean_cmd.rs) | +| `WEDGE` | _(no prompt — one-shot, dialog, or toggle)_ | [src/modules/model/primitive_cmd.rs](src/modules/model/primitive_cmd.rs) | + +## modules/view + +| Command | Initial prompt / usage | Source | +|---|---|---| +| `LIMITS` | LIMITS Specify first corner or [On / Off] <%{v}>: *(+1 more step)* | [src/modules/view/limits.rs](src/modules/view/limits.rs) | +| `PLOTWINDOW` | PLOTWINDOW Specify first corner of plot window: *(+1 more step)* | [src/modules/view/plot_window.rs](src/modules/view/plot_window.rs) | + +## Multi-step command detail + +Commands whose prompt changes as you answer — each step shown in order. Placeholders like `{:.4}` / `%{tag}` are runtime-substituted values (current radius, entered tag name, etc.), not literal text to type. + +### `3DFACE` + +1. 3DFACE Specify first point or [Invisible]: +2. 3DFACE Specify second point or [Invisible]: +3. 3DFACE Specify third point or [Invisible] : +4. 3DFACE Specify fourth point or [Invisible] : + +### `3DMESH` + +1. 3DMESH Enter size of mesh in M direction (2-256): +2. 3DMESH Enter size of mesh in N direction (2-256): +3. 3DMESH Specify location for vertex (%{m}, %{n}): + +### `3DPOLY` + +1. 3DPOLY Specify start point of polyline: +2. 3DPOLY Specify next point [Undo]: +3. 3DPOLY Specify next point [Close/Undo]: + +### `ALIGN` + +1. ALIGN Select objects (%{count} selected, Enter when done): +2. ALIGN Specify 1st source point: +3. ALIGN Specify 1st destination point: +4. ALIGN Specify 2nd source point (Enter = translate only): +5. ALIGN Specify 2nd destination point: +6. ALIGN Scale objects based on alignment points? [Yes / No]: + +### `ARC_3P` + +1. ARC 3P Specify start point: +2. ARC 3P Specify second point on arc: +3. ARC 3P Specify end point: + +### `ARC_CSA` + +1. ARC CSA Specify center: +2. ARC CSA Specify start point: +3. ARC CSA Click end direction or type arc span in degrees [start=%{sa}]: + +### `ARC_CSE` + +1. ARC Specify center: +2. ARC Specify start point: +3. ARC Specify end point [c=(%{cx},%{cy}) r=%{r}]: + +### `ARC_CSL` + +1. ARC CSL Specify center: +2. ARC CSL Specify start point: +3. ARC CSL Click chord end or type chord length [r=%{r}]: + +### `ARC_SCA` + +1. ARC SCA Specify start point: +2. ARC SCA Specify center: +3. ARC SCA Click end direction or type arc span in degrees [start=%{sa}]: + +### `ARC_SCE` + +1. ARC SCE Specify start point: +2. ARC SCE Specify center: +3. ARC SCE Specify end point [r=%{r}]: + +### `ARC_SCL` + +1. ARC SCL Specify start point: +2. ARC SCL Specify center: +3. ARC SCL Click chord end or type chord length [r=%{r}]: + +### `ARC_SEA` + +1. ARC SEA Specify start point: +2. ARC SEA Specify end point: +3. ARC SEA Specify angle (move cursor perpendicular to chord): + +### `ARC_SED` + +1. ARC SED Specify start point: +2. ARC SED Specify end point: +3. ARC SED Specify tangent direction at start: + +### `ARC_SER` + +1. ARC SER Specify start point: +2. ARC SER Specify end point: +3. ARC SER Click radius point or type radius value: + +### `ARRAY3D` + +1. ARRAY3D Enter row count: +2. ARRAY3D Enter column count [%{rows} rows]: +3. ARRAY3D Enter level count [%{rows}×%{cols}]: +4. ARRAY3D Row spacing [%{rows}×%{cols}×%{levels}]: +5. ARRAY3D Column spacing [%{rows}×%{cols}×%{levels}, row=%{rs}]: +6. ARRAY3D Level spacing [%{rows}×%{cols}×%{levels}, r=%{rs} c=%{cs}]: + +### `ATTDEF` + +1. ATTDEF Enter attribute tag (no spaces): +2. ATTDEF Enter prompt for '%{tag}' (Enter=use tag): +3. ATTDEF Enter default value for '%{tag}' (Enter=blank): +4. ATTDEF Specify insertion point for '%{tag}': + +### `BLEND` + +1. BLEND Select first open curve [Continuity=%{c}]: +2. BLEND Enter continuity [Tangent/Curvature] <%{c}>: +3. BLEND Select second open curve [Continuity=%{c}]: + +### `BOUNDARY` + +1. ⚠ No closed boundary found. +2. BOUNDARY Pick internal point:%{miss} +3. %{cmd} Select objects: +4. Tolerance + +### `BREAK` + +1. BREAK Select object: +2. BREAK Specify first break point: +3. BREAK Specify second break point: + +### `BREAKATPOINT` + +1. BREAKATPOINT Select object: +2. BREAKATPOINT Specify break point: + +### `CENTERLINE` + +1. CENTERLINE Select first line: +2. CENTERLINE Select second line: + +### `CHAMFER` + +1. CHAMFER Select first line [D1=%{d1} D2=%{d2}]: +2. CHAMFER Enter first chamfer distance <%{d1}>: +3. CHAMFER Enter second chamfer distance <%{d2}>: +4. CHAMFER Select second line [D1=%{d1} D2=%{d2}]: +5. CHAMFER Select the adjacent polyline segment [D1=%{d1} D2=%{d2}]: + +### `CIRCLE` + +1. CIRCLE Specify center point: +2. CIRCLE Specify radius or type value <%{r}> [center (%{cx},%{cy})]: +3. CIRCLE Specify diameter <{:.4}>: + +### `CIRCLE_2P` + +1. CIRCLE 2P Specify first end of diameter: +2. CIRCLE 2P Specify second end of diameter: + +### `CIRCLE_3P` + +1. CIRCLE 3P Specify first point: +2. CIRCLE 3P Specify second point: +3. CIRCLE 3P Specify third point: + +### `CIRCLE_CD` + +1. CIRCLE CD Specify center point: +2. CIRCLE CD Specify diameter or type value <{:.4}> [center ({:.3},{:.3})]: + +### `CIRCLE_TTR` + +1. CIRCLE TTR Select first tangent object: +2. CIRCLE TTR Select second tangent object: +3. CIRCLE TTR Specify radius: + +### `CIRCLE_TTT` + +1. CIRCLE TTT Select first tangent object: +2. CIRCLE TTT Select second tangent object: +3. CIRCLE TTT Select third tangent object: + +### `DIMALIGNED` + +1. DIMALIGNED Enter formatted dimension text (blank = measured value): +2. DIMALIGNED Enter dimension text (blank = measured value): +3. DIMALIGNED Specify text angle (degrees): +4. DIMALIGNED Select object to dimension: +5. DIMALIGNED Specify first extension line origin or press Enter to select object: +6. DIMALIGNED Specify second extension line origin: +7. DIMALIGNED Specify dimension line location [Mtext/Text/Angle]: + +### `DIMANGULAR` + +1. DIMANGULAR Specify quadrant: +2. DIMANGULAR Enter formatted dimension text (blank = measured value): +3. DIMANGULAR Enter dimension text (blank = measured value): +4. DIMANGULAR Specify second point for text angle: +5. DIMANGULAR Specify text angle or first point: +6. DIMANGULAR Select second line: +7. DIMANGULAR Select arc, circle, line, or specify an angle vertex: +8. DIMANGULAR Specify angle vertex: +9. DIMANGULAR Specify first extension line point: +10. DIMANGULAR Specify second extension line point: +11. DIMANGULAR Select second line: +12. DIMANGULAR Specify dimension arc location [Mtext/Text/Angle/Quadrant]: + +### `DIMARC` + +1. DIMARC Enter dimension text (blank = measured value): +2. DIMARC Specify text angle (degrees): +3. DIMARC Select arc or polyline arc segment: +4. DIMARC Specify arc length dimension location [Mtext/Text/Angle/Partial{leader_option}]: +5. DIMARC Specify first point of partial arc: +6. DIMARC Specify second point of partial arc: + +### `DIMBASELINE` + +1. DIMBASELINE Select base dimension: +2. DIMBASELINE Specify feature location [Undo/Select] : + +### `DIMBREAK` + +1. DIMBREAK Select dimension to break: +2. DIMBREAK Select object to break at, or Enter for Auto: + +### `DIMCONTINUE` + +1. DIMCONTINUE Select continued dimension: +2. DIMCONTINUE Specify feature location [Undo/Select] : + +### `DIMDIAMETER` + +1. DIMDIAMETER Enter dimension text (blank = measured value): +2. DIMDIAMETER Specify text angle (degrees): +3. DIMDIAMETER Select arc, circle, or polyline arc: +4. DIMDIAMETER Specify dimension line location [Mtext/Text/Angle]: + +### `DIMEDIT` + +1. DIMEDIT Select dimension: +2. DIMEDIT Enter text override (blank = reset to measured): + +### `DIMJOGGED` + +1. DIMJOGGED Enter dimension text (blank = measured value): +2. DIMJOGGED Specify text angle (degrees): +3. DIMJOGGED Select arc, circle, or polyline arc: +4. DIMJOGGED Specify center location override: +5. DIMJOGGED Specify dimension line location [Mtext/Text/Angle]: +6. DIMJOGGED Specify jog location: + +### `DIMJOGLINE` + +1. DIMJOGLINE Select linear or aligned dimension: +2. DIMJOGLINE Specify jog location: + +### `DIMLINEAR` + +1. DIMLINEAR Enter formatted dimension text (blank = measured value): +2. DIMLINEAR Enter dimension text (blank = measured value): +3. DIMLINEAR Specify text angle (degrees): +4. DIMLINEAR Specify dimension line angle (degrees): +5. DIMLINEAR Select object to dimension: +6. DIMLINEAR Specify first extension line origin or press Enter to select object: +7. DIMLINEAR Specify second extension line origin: +8. DIMLINEAR Specify dimension line location [Mtext/Text/Angle/Horizontal/Vertical/Rotated]: + +### `DIMORDINATE` + +1. DIMORDINATE Enter dimension text (blank = measured value): +2. DIMORDINATE Specify text angle (degrees): +3. DIMORDINATE Specify feature location: +4. DIMORDINATE Specify leader endpoint [Xdatum/Ydatum/Mtext/Text/Angle]: + +### `DIMRADIUS` + +1. DIMRADIUS Enter dimension text (blank = measured value): +2. DIMRADIUS Specify text angle (degrees): +3. DIMRADIUS Select arc, circle, or polyline arc: +4. DIMRADIUS Specify dimension line location [Mtext/Text/Angle]: + +### `DIMSPACE` + +1. DIMSPACE Select base dimension: +2. DIMSPACE Select dimension to space (%{count} selected, Enter when done): +3. DIMSPACE Enter value (0 = auto): + +### `DIMTEDIT` + +1. DIMTEDIT Select dimension: +2. DIMTEDIT Specify new location for dimension text: + +### `DIST` + +1. DIST Specify first point: +2. DIST Specify second point: + +### `DIVIDE` + +1. DIVIDE Select object to divide: +2. DIVIDE Enter number of segments: + +### `DONUT` + +1. DONUT Specify inside diameter <0>: +2. DONUT Specify second point for inside diameter: +3. DONUT Specify outside diameter: +4. DONUT Specify second point for outside diameter: +5. DONUT Specify center of donut (Enter to exit): + +### `EDGE` + +1. EDGE Select edge of 3D face to toggle visibility or [Display]: +2. EDGE Display invisible edges [All/Select]