From 63a74b19a91a0c5d7faf65f4a2925fd20896f8ea Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 8 Sep 2026 22:19:23 +0200 Subject: [PATCH 01/99] Add persistent sketch-constraint system (ocs_gcs port) and named-parameters handoff docs Ports FreeCAD's planegcs 2D geometric constraint solver to Rust as a new crates/ocs_gcs workspace member, and wires it into the document/scene model as a persistent SketchConstraintSet: Coincident, Horizontal/Vertical, Parallel/Perpendicular, Equal, Tangent, Distance, Angle and Radius constraints that re-solve live (including mid-drag, not just on release), render glyphs and a DOF badge, flag conflicts with a one-click resolver pill, and round-trip through save/load via an XRecord blob. Also includes docs/command_reference.md (a statically generated reference of every registered command and its prompt sequence) and docs/named_parameters_design.md + docs/backlog.md, which hand off the next queued feature (a named-parameter/expression table) and consolidate every deliberate scope-down and open gap found along the way, so a fresh session has a complete starting point. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 6 + Cargo.lock | 92 ++ Cargo.toml | 2 + build.rs | 32 + crates/ocs_gcs/Cargo.toml | 13 + .../ocs_gcs/src/constraints/angle_distance.rs | 224 +++ crates/ocs_gcs/src/constraints/bspline.rs | 216 +++ crates/ocs_gcs/src/constraints/circle_arc.rs | 478 ++++++ crates/ocs_gcs/src/constraints/conic.rs | 424 +++++ .../ocs_gcs/src/constraints/curve_generic.rs | 498 ++++++ crates/ocs_gcs/src/constraints/mod.rs | 108 ++ crates/ocs_gcs/src/constraints/point_line.rs | 956 +++++++++++ crates/ocs_gcs/src/diagnosis.rs | 265 +++ crates/ocs_gcs/src/geo.rs | 1169 +++++++++++++ crates/ocs_gcs/src/graph.rs | 140 ++ crates/ocs_gcs/src/lib.rs | 15 + crates/ocs_gcs/src/qp_eq.rs | 182 +++ crates/ocs_gcs/src/solvers/bfgs.rs | 169 ++ crates/ocs_gcs/src/solvers/dogleg.rs | 209 +++ crates/ocs_gcs/src/solvers/lm.rs | 176 ++ crates/ocs_gcs/src/solvers/mod.rs | 38 + crates/ocs_gcs/src/subsystem.rs | 268 +++ crates/ocs_gcs/src/system.rs | 155 ++ crates/ocs_gcs/src/util.rs | 161 ++ docs/backlog.md | 148 ++ docs/command_reference.md | 1443 +++++++++++++++++ docs/named_parameters_design.md | 136 ++ docs/parametric_system_design.md | 1170 +++++++++++++ packaging/Info.plist | 4 +- packaging/build_macos_signed.sh | 11 +- src/app/automation.rs | 2 + src/app/command_driver.rs | 414 +++++ src/app/commands/draw.rs | 111 ++ src/app/config.rs | 17 +- src/app/document.rs | 43 + src/app/history.rs | 52 +- src/app/mod.rs | 18 +- src/app/update/file.rs | 12 + src/app/update/mod.rs | 16 + src/app/update/viewport.rs | 33 +- src/app/view/mod.rs | 47 + src/app/view/modal.rs | 2 + src/command.rs | 29 + src/entities/curve.rs | 12 +- src/modules/draw/constrain/coincident.rs | 73 + src/modules/draw/constrain/mod.rs | 21 + src/modules/draw/constrain/tools.rs | 75 + src/modules/draw/constrain/value.rs | 184 +++ src/modules/draw/mod.rs | 16 + src/scene/dimension_assoc.rs | 8 +- src/scene/mod.rs | 24 + src/scene/modify.rs | 5 + src/scene/pick/grip.rs | 6 +- src/scene/sketch_constraints.rs | 534 ++++++ src/scene/sketch_persist.rs | 248 +++ src/scene/sketch_solve.rs | 562 +++++++ src/ui/overlay.rs | 47 + src/ui/ribbon/mod.rs | 62 +- src/ui/ribbon/widgets.rs | 116 +- src/ui/statusbar/mod.rs | 67 + src/ui/window/options.rs | 52 + tests/sketch_constraints_solve.rs | 298 ++++ tests/sketch_constraints_xrecord_roundtrip.rs | 128 ++ 63 files changed, 12193 insertions(+), 49 deletions(-) create mode 100644 crates/ocs_gcs/Cargo.toml create mode 100644 crates/ocs_gcs/src/constraints/angle_distance.rs create mode 100644 crates/ocs_gcs/src/constraints/bspline.rs create mode 100644 crates/ocs_gcs/src/constraints/circle_arc.rs create mode 100644 crates/ocs_gcs/src/constraints/conic.rs create mode 100644 crates/ocs_gcs/src/constraints/curve_generic.rs create mode 100644 crates/ocs_gcs/src/constraints/mod.rs create mode 100644 crates/ocs_gcs/src/constraints/point_line.rs create mode 100644 crates/ocs_gcs/src/diagnosis.rs create mode 100644 crates/ocs_gcs/src/geo.rs create mode 100644 crates/ocs_gcs/src/graph.rs create mode 100644 crates/ocs_gcs/src/lib.rs create mode 100644 crates/ocs_gcs/src/qp_eq.rs create mode 100644 crates/ocs_gcs/src/solvers/bfgs.rs create mode 100644 crates/ocs_gcs/src/solvers/dogleg.rs create mode 100644 crates/ocs_gcs/src/solvers/lm.rs create mode 100644 crates/ocs_gcs/src/solvers/mod.rs create mode 100644 crates/ocs_gcs/src/subsystem.rs create mode 100644 crates/ocs_gcs/src/system.rs create mode 100644 crates/ocs_gcs/src/util.rs create mode 100644 docs/backlog.md create mode 100644 docs/command_reference.md create mode 100644 docs/named_parameters_design.md create mode 100644 docs/parametric_system_design.md create mode 100644 src/modules/draw/constrain/coincident.rs create mode 100644 src/modules/draw/constrain/mod.rs create mode 100644 src/modules/draw/constrain/tools.rs create mode 100644 src/modules/draw/constrain/value.rs create mode 100644 src/scene/sketch_constraints.rs create mode 100644 src/scene/sketch_persist.rs create mode 100644 src/scene/sketch_solve.rs create mode 100644 tests/sketch_constraints_solve.rs create mode 100644 tests/sketch_constraints_xrecord_roundtrip.rs 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] + + + + + +
+
+

OpenCAD Studio User Manual

+

3D Modeling and Visualization

+

Solids and surfaces

+

OpenCAD Studio can create and display solid primitives and modeled features such as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for display while exact body data is retained where supported.

+

Visual styles and lighting

+

Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, not the underlying object geometry. Switch to wireframe when diagnosing missing faces or material problems.

+

Tessellation quality

+

Curves and analytic surfaces are converted into display geometry according to view and tolerance settings. A coarse display does not necessarily mean the stored curve or solid is coarse.

+ + +
+
+ + diff --git a/help/README.md b/help/README.md new file mode 100644 index 000000000..236cce877 --- /dev/null +++ b/help/README.md @@ -0,0 +1,43 @@ +# OpenCAD Studio Help + +This directory is a self-contained, static user-help system. It is intentionally +not wired into the application yet. + +## Authoring model + +- `manual.md` is the single source of truth for user-facing prose. +- `tools/build_help.py` splits the level-two chapters into static HTML pages. +- `assets/css/` and `assets/js/` provide the shared responsive presentation, + navigation, dark mode, print styling, and local search. +- `context-map.json` reserves stable help targets for future command/dialog + integration without changing application source now. +- `assets/images/` is for screenshots and workflow illustrations referenced by + `manual.md`. + +Generated HTML and `search-index.js` are committed so the manual works directly +from disk without a web server. Do not edit generated pages by hand. + +## Build and validate + +Requirements: Python 3 and Pandoc. + +```sh +python3 help/tools/build_help.py +python3 help/tools/build_help.py --check +python3 help/tools/check_help.py +``` + +Open `help/index.html` in a browser to review the generated manual. When future +application work is safe, package the complete `help/` directory and connect the +Help menu or contextual controls to the URLs in `context-map.json`. + +## Content rules + +1. Keep level-two headings unique; each one is a chapter boundary. +2. Keep level-three headings stable because their generated IDs are public help + targets. +3. Describe observable behavior, not implementation details. +4. Use task-first procedures and state prerequisites before numbered steps. +5. Mark safety, compatibility, data-loss, and known limitations explicitly. +6. Regenerate and run both checks before committing documentation changes. + diff --git a/help/annotation-dimensions.html b/help/annotation-dimensions.html new file mode 100644 index 000000000..d1f794516 --- /dev/null +++ b/help/annotation-dimensions.html @@ -0,0 +1,55 @@ + + + + + + + Annotation and Dimensions — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Annotation and Dimensions

+

Text and annotation

+

Single-line text, multiline text, leaders, multileaders, tables, tolerances, center marks, and centerlines communicate design intent. Missing fonts can change layout or substitute glyphs, especially in the web build.

+

Dimensions

+

Dimensions combine measured geometry with a dimension style. Associative dimensions update when their source geometry changes; non-associative or partially associated dimensions require manual verification.

+

Annotation scale

+

Annotative objects can display differently at different viewport scales. Confirm the active annotation scale before creating annotations and inspect all paper-space viewports before plotting.

+ + +
+
+ + diff --git a/help/assets/css/core.css b/help/assets/css/core.css new file mode 100644 index 000000000..f1e77ce0c --- /dev/null +++ b/help/assets/css/core.css @@ -0,0 +1,190 @@ +:root { + color-scheme: light dark; + --accent: #1677d2; + --accent-strong: #075cae; + --accent-soft: #eaf4ff; + --canvas: #ffffff; + --chrome: rgba(20, 27, 37, 0.96); + --line: #d7dee7; + --muted: #617083; + --sidebar: #f4f7fa; + --text: #16202c; + --warning: #fff4cc; + --danger: #a62b2b; + --sidebar-width: 286px; + --topbar-height: 62px; +} + +* { box-sizing: border-box; } +html { scroll-padding-top: calc(var(--topbar-height) + 20px); } + +body { + margin: 0; + background: var(--canvas); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.62; +} + +a { color: var(--accent-strong); text-decoration-thickness: .08em; text-underline-offset: .16em; } +a:hover { color: var(--accent); } +a:focus-visible, button:focus-visible, input:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 2px; +} + +.topbar { + position: fixed; + inset: 0 0 auto; + z-index: 30; + height: var(--topbar-height); + display: flex; + align-items: center; + gap: 14px; + padding: 0 18px; + background: var(--chrome); + border-bottom: 1px solid rgba(255, 255, 255, .13); + backdrop-filter: blur(16px); +} + +.brand { min-width: 190px; color: #fff; font-size: 18px; font-weight: 720; text-decoration: none; } +.brand:hover { color: #fff; } +.nav-toggle { + width: 38px; + height: 38px; + border: 0; + border-radius: 8px; + background: transparent; + color: #fff; + cursor: pointer; + font-size: 22px; +} +.nav-toggle:hover { background: rgba(255, 255, 255, .12); } + +.search-shell { position: relative; width: min(520px, 55vw); margin-left: auto; } +.help-search { + width: 100%; + height: 38px; + padding: 0 42px 0 13px; + border: 1px solid rgba(255, 255, 255, .24); + border-radius: 9px; + background: rgba(255, 255, 255, .11); + color: #fff; + font: inherit; +} +.help-search::placeholder { color: rgba(255, 255, 255, .68); } +.search-key { position: absolute; top: 8px; right: 10px; color: rgba(255, 255, 255, .64); font-size: 13px; } +.search-results { + position: absolute; + top: 44px; + right: 0; + width: min(620px, calc(100vw - 24px)); + max-height: min(70vh, 560px); + overflow: auto; + margin: 0; + padding: 7px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--canvas); + box-shadow: 0 18px 50px rgba(0, 0, 0, .28); + list-style: none; +} +.search-results[hidden] { display: none; } +.search-results a { display: block; padding: 10px 11px; border-radius: 7px; color: var(--text); text-decoration: none; } +.search-results a:hover, .search-results a[aria-selected="true"] { background: var(--accent-soft); } +.search-results strong { display: block; color: var(--accent-strong); } +.search-results span { display: block; color: var(--muted); font-size: 13px; } +.search-empty { padding: 12px; color: var(--muted); } + +.sidebar { + position: fixed; + z-index: 20; + inset: var(--topbar-height) auto 0 0; + width: var(--sidebar-width); + padding: 16px 13px 36px; + overflow: auto; + background: var(--sidebar); + border-right: 1px solid var(--line); + transition: transform 180ms ease; +} +body.nav-collapsed .sidebar { transform: translateX(-100%); } +.sidebar details, .sidebar > div { margin: 3px 0; border-radius: 8px; } +.sidebar details.current-topic, .sidebar > div.current-topic { background: var(--canvas); box-shadow: 0 0 0 1px var(--line); } +.sidebar summary { padding: 7px 9px; font-weight: 680; cursor: pointer; } +.sidebar .chapter-link { display: block; padding: 7px 9px; font-weight: 680; } +.sidebar ul { margin: 0; padding: 0 9px 9px 27px; list-style: none; } +.sidebar li { margin: 2px 0; line-height: 1.35; } +.sidebar li a { display: block; padding: 3px 0; color: var(--text); font-size: 13px; text-decoration: none; } +.sidebar li a:hover { color: var(--accent); } + +#main-content { + min-height: 100vh; + margin-left: var(--sidebar-width); + padding: calc(var(--topbar-height) + 38px) 34px 70px; + transition: margin-left 180ms ease; +} +body.nav-collapsed #main-content { margin-left: 0; } +.content-wrap { max-width: 940px; margin: 0 auto; } +.page-kicker { margin: 0; color: var(--accent-strong); font-size: 12px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; } +.content-title { margin: 3px 0 30px; font-size: clamp(34px, 6vw, 52px); line-height: 1.06; letter-spacing: -.035em; } + +h2 { margin: 44px 0 17px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); font-size: 23px; line-height: 1.3; } +h3 { margin: 34px 0 8px; font-size: 19px; line-height: 1.35; } +p { margin: 9px 0 16px; } +ul, ol { padding-left: 26px; } +li { margin: 5px 0; } +code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; } +code { color: #a0440c; font-size: .91em; } +blockquote { + margin: 18px 0; + padding: 12px 15px; + border: 1px solid color-mix(in srgb, #d5a000 45%, var(--line)); + border-left: 4px solid #d5a000; + border-radius: 8px; + background: var(--warning); + color: #503f05; +} +blockquote p { margin: 0; } + +.page-navigation { display: flex; justify-content: space-between; gap: 20px; margin-top: 58px; padding-top: 18px; border-top: 1px solid var(--line); } +.page-navigation a:last-child { margin-left: auto; } +.date-footer { margin-top: 34px; color: var(--muted); font-size: 13px; text-align: center; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 760px) { + .brand { min-width: 0; font-size: 16px; } + .search-shell { width: min(48vw, 420px); } + .search-key { display: none; } + .sidebar { box-shadow: 12px 0 34px rgba(0, 0, 0, .22); } + body:not(.nav-open) .sidebar { transform: translateX(-100%); } + body.nav-open .sidebar { transform: translateX(0); } + #main-content, body.nav-collapsed #main-content { margin-left: 0; padding: calc(var(--topbar-height) + 28px) 18px 52px; } +} + +@media (prefers-color-scheme: dark) { + :root { + --accent: #66b6ff; + --accent-strong: #8bc7ff; + --accent-soft: #17344f; + --canvas: #161b22; + --chrome: rgba(14, 18, 24, .96); + --line: #374252; + --muted: #9eabba; + --sidebar: #1d242d; + --text: #eef3f8; + --warning: #423819; + } + code { color: #ffae79; } + blockquote { color: #f5e6ad; } +} + +@media print { + .topbar, .sidebar, .page-navigation { display: none !important; } + #main-content { margin: 0; padding: 0; } + body { color: #000; background: #fff; font-size: 11pt; } + a { color: inherit; } + h2, h3 { break-after: avoid; } + blockquote { break-inside: avoid; } +} + diff --git a/help/assets/css/opencad.css b/help/assets/css/opencad.css new file mode 100644 index 000000000..35f814a02 --- /dev/null +++ b/help/assets/css/opencad.css @@ -0,0 +1,24 @@ +.hero { + margin: 0 0 38px; + padding: clamp(25px, 6vw, 56px); + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); + border-radius: 18px; + background: + linear-gradient(115deg, color-mix(in srgb, var(--accent-soft) 82%, var(--canvas)), var(--canvas)); +} +.hero .eyebrow { margin: 0; color: var(--accent-strong); font-size: 12px; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; } +.hero h2 { margin: 8px 0 14px; padding: 0; border: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.08; letter-spacing: -.03em; } +.hero p:last-child { max-width: 720px; margin-bottom: 0; font-size: 17px; } +.chapter-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.chapter-card { min-height: 130px; display: flex; flex-direction: column; gap: 7px; padding: 18px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); text-decoration: none; } +.chapter-card:hover { border-color: var(--accent); background: var(--accent-soft); text-decoration: none; } +.chapter-card strong { color: var(--accent-strong); font-size: 18px; } +.chapter-card span { color: var(--muted); } +.quick-paths { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 18px 0 28px; } +.quick-paths a { padding: 12px; border: 1px solid var(--line); border-radius: 9px; text-align: center; text-decoration: none; } +.quick-paths a:hover { background: var(--accent-soft); } +@media (max-width: 640px) { + .chapter-grid, .quick-paths { grid-template-columns: 1fr; } + .chapter-card { min-height: 0; } +} + diff --git a/help/assets/images/README.md b/help/assets/images/README.md new file mode 100644 index 000000000..ebe08c420 --- /dev/null +++ b/help/assets/images/README.md @@ -0,0 +1,9 @@ +# Help images + +Place optimized screenshots and workflow illustrations in this directory. + +Use descriptive lowercase filenames, include meaningful alternative text in +`manual.md`, crop away unrelated desktop content, and avoid confidential drawing +names or paths. Prefer WebP or optimized PNG and keep each image at the smallest +resolution that remains legible on a high-density display. + diff --git a/help/assets/js/help.js b/help/assets/js/help.js new file mode 100644 index 000000000..336311a5c --- /dev/null +++ b/help/assets/js/help.js @@ -0,0 +1,120 @@ +(function () { + "use strict"; + + const body = document.body; + const toggle = document.querySelector(".nav-toggle"); + const sidebar = document.getElementById("help-sidebar"); + const search = document.getElementById("help-search"); + const results = document.getElementById("search-results"); + const mobile = window.matchMedia("(max-width: 760px)"); + let selected = -1; + + function syncToggle() { + const expanded = mobile.matches + ? body.classList.contains("nav-open") + : !body.classList.contains("nav-collapsed"); + toggle?.setAttribute("aria-expanded", String(expanded)); + } + + toggle?.addEventListener("click", function () { + body.classList.toggle(mobile.matches ? "nav-open" : "nav-collapsed"); + syncToggle(); + }); + + sidebar?.addEventListener("click", function (event) { + if (mobile.matches && event.target.closest("a")) { + body.classList.remove("nav-open"); + syncToggle(); + } + }); + + mobile.addEventListener("change", function () { + body.classList.remove("nav-open"); + syncToggle(); + }); + + function hideResults() { + results.hidden = true; + results.innerHTML = ""; + selected = -1; + search?.setAttribute("aria-expanded", "false"); + } + + function renderResults(query) { + const terms = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean); + if (!terms.length) { + hideResults(); + return; + } + + const matches = (window.OCS_HELP_INDEX || []).map(function (entry) { + const title = entry.title.toLocaleLowerCase(); + const text = entry.text.toLocaleLowerCase(); + const score = terms.reduce(function (total, term) { + if (!text.includes(term) && !title.includes(term)) return -1000; + return total + (title.includes(term) ? 8 : 1); + }, 0); + return { entry: entry, score: score }; + }).filter(function (item) { return item.score >= 0; }) + .sort(function (a, b) { return b.score - a.score || a.entry.title.localeCompare(b.entry.title); }) + .slice(0, 12); + + results.innerHTML = ""; + if (!matches.length) { + const empty = document.createElement("li"); + empty.className = "search-empty"; + empty.textContent = "No matching help topics."; + results.appendChild(empty); + } else { + matches.forEach(function (item) { + const li = document.createElement("li"); + const link = document.createElement("a"); + link.href = item.entry.url; + const strong = document.createElement("strong"); + strong.textContent = item.entry.title; + const detail = document.createElement("span"); + detail.textContent = item.entry.chapter; + link.append(strong, detail); + li.appendChild(link); + results.appendChild(li); + }); + } + results.hidden = false; + search.setAttribute("aria-expanded", "true"); + selected = -1; + } + + search?.addEventListener("input", function () { renderResults(search.value); }); + search?.addEventListener("keydown", function (event) { + const links = Array.from(results.querySelectorAll("a")); + if (event.key === "Escape") { + search.value = ""; + hideResults(); + return; + } + if (!links.length || !["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Enter" && selected >= 0) { + links[selected].click(); + return; + } + if (event.key === "ArrowDown") selected = (selected + 1) % links.length; + if (event.key === "ArrowUp") selected = selected < 0 ? links.length - 1 : (selected - 1 + links.length) % links.length; + links.forEach(function (link, index) { link.setAttribute("aria-selected", String(index === selected)); }); + links[selected]?.scrollIntoView({ block: "nearest" }); + }); + + document.addEventListener("keydown", function (event) { + const shortcut = event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"); + if (shortcut && document.activeElement !== search) { + event.preventDefault(); + search?.focus(); + } + }); + + document.addEventListener("click", function (event) { + if (!event.target.closest(".search-shell")) hideResults(); + }); + + syncToggle(); +}()); diff --git a/help/automation-plugins.html b/help/automation-plugins.html new file mode 100644 index 000000000..772d1d5c1 --- /dev/null +++ b/help/automation-plugins.html @@ -0,0 +1,55 @@ + + + + + + + Automation and Plugins — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Automation and Plugins

+

Automation interface

+

The automation interface can inspect documents, run commands, query geometry, capture the viewport, and perform batches. Callers should use request IDs and document revisions to avoid repeating or applying work to stale state.

+

Plugins

+

Native plugins can contribute commands and ribbon surfaces through the plugin host. Install only trusted packages that match the host API, compiler, and CAD model compatibility requirements.

+

Native and web differences

+

The browser build uses in-memory file workflows and cannot load native plugin libraries. Some rendering, font, windowing, and parallel-processing behavior also differs from the desktop build.

+ + +
+
+ + diff --git a/help/blocks-references.html b/help/blocks-references.html new file mode 100644 index 000000000..8502f0729 --- /dev/null +++ b/help/blocks-references.html @@ -0,0 +1,55 @@ + + + + + + + Blocks and References — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Blocks and References

+

Create and insert blocks

+

A block definition groups reusable drawing content; an INSERT places an instance with its own position, rotation, scale, attributes, and optional array settings. Editing a definition affects every reference to it.

+

Array inserts

+

Array inserts place multiple block instances with row and column counts and spacing. Large counts can generate substantial geometry. Validate scaled and rotated arrays when exchanging drawings with another CAD system.

+

Attributes and external references

+

Attributes attach editable values to block references. External references and underlays retain links to other files; preserve their paths when sharing or archiving a project.

+ + +
+
+ + diff --git a/help/commands-shortcuts.html b/help/commands-shortcuts.html new file mode 100644 index 000000000..8471c3caf --- /dev/null +++ b/help/commands-shortcuts.html @@ -0,0 +1,55 @@ + + + + + + + Commands and Shortcuts — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Commands and Shortcuts

+

Command line

+

Enter a command name or alias, then respond to its prompts. Press Escape to cancel the active command and Return to accept an available default. Exact prompt options vary by command and current selection.

+

Discover commands

+

Use command autocomplete and the ribbon to discover available operations. Some commands are one-shot actions; others maintain state across several point, number, keyword, or object-selection inputs.

+

Shortcut conventions

+

Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows.

+ + +
+
+ + diff --git a/help/context-map.json b/help/context-map.json new file mode 100644 index 000000000..d6bf4d820 --- /dev/null +++ b/help/context-map.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "fallback": "index.html", + "commands": { + "NEW": "files-drawings.html#create-and-open-drawings", + "OPEN": "files-drawings.html#create-and-open-drawings", + "SAVE": "files-drawings.html#save-and-save-as", + "SAVEAS": "files-drawings.html#save-and-save-as", + "RECOVER": "files-drawings.html#recovery-and-backups", + "LINE": "create-edit-geometry.html#basic-drawing-commands", + "CIRCLE": "create-edit-geometry.html#basic-drawing-commands", + "PLINE": "create-edit-geometry.html#basic-drawing-commands", + "ERASE": "create-edit-geometry.html#modify-geometry", + "UNDO": "create-edit-geometry.html#undo-redo-and-oops", + "REDO": "create-edit-geometry.html#undo-redo-and-oops", + "OOPS": "create-edit-geometry.html#undo-redo-and-oops", + "LAYER": "layers-properties.html#layers", + "CHPROP": "layers-properties.html#object-properties", + "BLOCK": "blocks-references.html#create-and-insert-blocks", + "INSERT": "blocks-references.html#create-and-insert-blocks", + "MINSERT": "blocks-references.html#array-inserts", + "DIM": "annotation-dimensions.html#dimensions", + "PLOT": "layouts-plotting.html#page-setup-and-plot-preview", + "EXTRUDE": "3d-modeling.html#solids-and-surfaces", + "REVOLVE": "3d-modeling.html#solids-and-surfaces", + "HCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "VCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "PCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "QCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "ECONSTRAINT": "parametric-constraints.html#geometric-constraints", + "CCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "TCONSTRAINT": "parametric-constraints.html#geometric-constraints" + }, + "surfaces": { + "file-open": "files-drawings.html#create-and-open-drawings", + "file-save": "files-drawings.html#save-and-save-as", + "layers": "layers-properties.html#layers", + "properties": "layers-properties.html#object-properties", + "plot": "layouts-plotting.html#page-setup-and-plot-preview", + "plugin-manager": "automation-plugins.html#plugins", + "constraint-conflicts": "parametric-constraints.html#editing-constrained-geometry", + "recovery": "troubleshooting-recovery.html#a-drawing-opens-incorrectly" + } +} + diff --git a/help/create-edit-geometry.html b/help/create-edit-geometry.html new file mode 100644 index 000000000..0e35516f7 --- /dev/null +++ b/help/create-edit-geometry.html @@ -0,0 +1,57 @@ + + + + + + + Create and Edit Geometry — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Create and Edit Geometry

+

Basic drawing commands

+

Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, and other supported entities from the ribbon or command line. Follow the active prompt: multi-step commands remain active until they complete or are cancelled.

+

Modify geometry

+

Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification.

+

Grips and direct manipulation

+

Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it.

+

Undo, redo, and OOPS

+

Undo reverses recorded document operations and Redo reapplies them. OOPS restores the most recently erased entities without reversing later commands. Save before a risky sequence and verify that associated dimensions, hatches, groups, and constraints were restored with their geometry.

+ + +
+
+ + diff --git a/help/files-drawings.html b/help/files-drawings.html new file mode 100644 index 000000000..643206a8d --- /dev/null +++ b/help/files-drawings.html @@ -0,0 +1,57 @@ + + + + + + + Files and Drawings — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Files and Drawings

+

Create and open drawings

+

Use New for a blank document and Open for an existing DWG, DXF, backup, or autosave drawing. After opening a file from another CAD system, inspect its units, layers, blocks, fonts, raster references, layouts, and model extents.

+

Save and Save As

+

Save updates the current drawing. Save As changes its path, format, or version. Prefer Save As when validating interoperability or recovering an older drawing. A successful write does not replace visual inspection of a reopened copy.

+

Recovery and backups

+

OpenCAD Studio recognizes drawing backups and autosave-style files where the underlying data is DWG or DXF. If strict loading rejects malformed entities, use the recovery workflow and review what was discarded before continuing work.

+

External resources

+

Raster images, underlays, fonts, materials, and external references may depend on paths outside the drawing. Keep referenced files together when moving a project and verify that relative paths still resolve.

+ + +
+
+ + diff --git a/help/getting-started.html b/help/getting-started.html new file mode 100644 index 000000000..7a6d82d1d --- /dev/null +++ b/help/getting-started.html @@ -0,0 +1,64 @@ + + + + + + + Getting Started — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Getting Started

+

What OpenCAD Studio does

+

OpenCAD Studio is a desktop and web CAD application for opening, creating, editing, visualizing, and saving two- and three-dimensional drawings. It works with DWG and DXF drawings and provides command-line, ribbon, automation, and plugin-oriented workflows.

+

A first drawing

+
    +
  1. Create a new drawing or open an existing DWG or DXF file.
  2. +
  3. Confirm the active workspace, units, layer, and object-snap settings.
  4. +
  5. Create geometry with a drawing command such as Line, Circle, or Polyline.
  6. +
  7. Edit the result with grips or a modification command.
  8. +
  9. Save the drawing under a new name until its compatibility has been verified.
  10. +
+
+

Data safety: Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file.

+
+

How to use this manual

+

Use the chapter navigation for broad workflows, the search box for a command or concept, and the previous/next links for sequential reading. Command names, system variables, keys, and file extensions appear in monospaced text.

+ + +
+
+ + diff --git a/help/index.html b/help/index.html new file mode 100644 index 000000000..9f4dbed96 --- /dev/null +++ b/help/index.html @@ -0,0 +1,61 @@ + + + + + + + Help overview — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Help overview

+
+

OpenCAD Studio User Manual

+

Find the workflow, command, or concept you need

+

Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.

+
+ +

Manual chapters

+ + + +
+
+ + diff --git a/help/layers-properties.html b/help/layers-properties.html new file mode 100644 index 000000000..457bfe56b --- /dev/null +++ b/help/layers-properties.html @@ -0,0 +1,55 @@ + + + + + + + Layers and Properties — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Layers and Properties

+

Layers

+

Layers organize geometry and control visibility, locking, color, line type, line weight, and plot behavior. Set the intended active layer before creating objects. Avoid editing objects on locked layers.

+

Object properties

+

Properties can be assigned explicitly or inherited ByLayer or ByBlock. Use the Properties surface or commands such as CHPROP to inspect and change selected objects.

+

Styles

+

Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving.

+ + +
+
+ + diff --git a/help/layouts-plotting.html b/help/layouts-plotting.html new file mode 100644 index 000000000..9a1214662 --- /dev/null +++ b/help/layouts-plotting.html @@ -0,0 +1,55 @@ + + + + + + + Layouts and Plotting — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Layouts and Plotting

+

Layouts and viewports

+

Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes.

+

Page setup and plot preview

+

Choose the target device or file format, paper size, orientation, plot area, scale, style behavior, and output quality. Use preview to catch clipping, incorrect line weights, hidden layers, or an unintended plot area.

+

Export checks

+

Before distributing a PDF or plotted file, compare it with the drawing at the intended sheet size and verify text, dimensions, raster content, transparency, line types, and viewport scales.

+ + +
+
+ + diff --git a/help/manual.md b/help/manual.md new file mode 100644 index 000000000..b366e7004 --- /dev/null +++ b/help/manual.md @@ -0,0 +1,305 @@ +# OpenCAD Studio User Manual + +This manual describes the user-facing workflows of OpenCAD Studio. The initial +version establishes the help structure and high-level guidance; individual +commands should be expanded and verified against released behavior before the +manual is published as complete. + +## Getting Started + +### What OpenCAD Studio does + +OpenCAD Studio is a desktop and web CAD application for opening, creating, +editing, visualizing, and saving two- and three-dimensional drawings. It works +with DWG and DXF drawings and provides command-line, ribbon, automation, and +plugin-oriented workflows. + +### A first drawing + +1. Create a new drawing or open an existing DWG or DXF file. +2. Confirm the active workspace, units, layer, and object-snap settings. +3. Create geometry with a drawing command such as **Line**, **Circle**, or + **Polyline**. +4. Edit the result with grips or a modification command. +5. Save the drawing under a new name until its compatibility has been verified. + +> **Data safety:** Keep an untouched copy of important third-party drawings. +> Confirm geometry, layouts, external references, and plotted output before +> replacing the original file. + +### How to use this manual + +Use the chapter navigation for broad workflows, the search box for a command or +concept, and the previous/next links for sequential reading. Command names, +system variables, keys, and file extensions appear in `monospaced text`. + +## Files and Drawings + +### Create and open drawings + +Use **New** for a blank document and **Open** for an existing DWG, DXF, backup, +or autosave drawing. After opening a file from another CAD system, inspect its +units, layers, blocks, fonts, raster references, layouts, and model extents. + +### Save and Save As + +**Save** updates the current drawing. **Save As** changes its path, format, or +version. Prefer Save As when validating interoperability or recovering an older +drawing. A successful write does not replace visual inspection of a reopened +copy. + +### Recovery and backups + +OpenCAD Studio recognizes drawing backups and autosave-style files where the +underlying data is DWG or DXF. If strict loading rejects malformed entities, use +the recovery workflow and review what was discarded before continuing work. + +### External resources + +Raster images, underlays, fonts, materials, and external references may depend +on paths outside the drawing. Keep referenced files together when moving a +project and verify that relative paths still resolve. + +## Workspace and Navigation + +### Model space and paper space + +Model space is the primary drawing environment. Paper-space layouts arrange +views for plotting through layout viewports. Always confirm the active space +before drawing or editing. + +### Pan, zoom, and orbit + +Use pan and zoom to navigate without changing drawing geometry. Use orbit and +view controls for three-dimensional inspection. **Zoom Extents** is useful when +the drawing appears empty or geometry is far from the expected location. + +### Multiple model viewports + +Model viewports can show different views of the same document. Grid, snap, +camera, and render mode may vary by viewport; confirm which viewport is active +before entering coordinates. + +### Selection and object snaps + +Selection identifies the objects a command will modify. Object snaps target +precise geometric locations such as endpoints, midpoints, centers, and +intersections. Zoom in and verify the snap marker when precision matters. + +## Create and Edit Geometry + +### Basic drawing commands + +Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, +and other supported entities from the ribbon or command line. Follow the active +prompt: multi-step commands remain active until they complete or are cancelled. + +### Modify geometry + +Common modification workflows include move, copy, rotate, scale, mirror, +offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked +layers can prevent modification. + +### Grips and direct manipulation + +Select an entity to expose its grips. Dragging a grip previews and then commits +a geometric change. Constrained neighboring geometry may update with it. + +### Undo, redo, and OOPS + +**Undo** reverses recorded document operations and **Redo** reapplies them. +**OOPS** restores the most recently erased entities without reversing later +commands. Save before a risky sequence and verify that associated dimensions, +hatches, groups, and constraints were restored with their geometry. + +## Layers and Properties + +### Layers + +Layers organize geometry and control visibility, locking, color, line type, +line weight, and plot behavior. Set the intended active layer before creating +objects. Avoid editing objects on locked layers. + +### Object properties + +Properties can be assigned explicitly or inherited **ByLayer** or **ByBlock**. +Use the Properties surface or commands such as `CHPROP` to inspect and change +selected objects. + +### Styles + +Text, dimension, multiline, table, and visual styles centralize presentation. +Changing a shared style can update many objects, so inspect affected layouts +before saving. + +## Blocks and References + +### Create and insert blocks + +A block definition groups reusable drawing content; an INSERT places an +instance with its own position, rotation, scale, attributes, and optional array +settings. Editing a definition affects every reference to it. + +### Array inserts + +Array inserts place multiple block instances with row and column counts and +spacing. Large counts can generate substantial geometry. Validate scaled and +rotated arrays when exchanging drawings with another CAD system. + +### Attributes and external references + +Attributes attach editable values to block references. External references and +underlays retain links to other files; preserve their paths when sharing or +archiving a project. + +## Annotation and Dimensions + +### Text and annotation + +Single-line text, multiline text, leaders, multileaders, tables, tolerances, +center marks, and centerlines communicate design intent. Missing fonts can +change layout or substitute glyphs, especially in the web build. + +### Dimensions + +Dimensions combine measured geometry with a dimension style. Associative +dimensions update when their source geometry changes; non-associative or +partially associated dimensions require manual verification. + +### Annotation scale + +Annotative objects can display differently at different viewport scales. +Confirm the active annotation scale before creating annotations and inspect all +paper-space viewports before plotting. + +## Layouts and Plotting + +### Layouts and viewports + +Layouts represent sheets. Paper-space viewports expose model-space views at +controlled scales. Lock finished viewports to avoid accidental view changes. + +### Page setup and plot preview + +Choose the target device or file format, paper size, orientation, plot area, +scale, style behavior, and output quality. Use preview to catch clipping, +incorrect line weights, hidden layers, or an unintended plot area. + +### Export checks + +Before distributing a PDF or plotted file, compare it with the drawing at the +intended sheet size and verify text, dimensions, raster content, transparency, +line types, and viewport scales. + +## 3D Modeling and Visualization + +### Solids and surfaces + +OpenCAD Studio can create and display solid primitives and modeled features such +as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for +display while exact body data is retained where supported. + +### Visual styles and lighting + +Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, +not the underlying object geometry. Switch to wireframe when diagnosing missing +faces or material problems. + +### Tessellation quality + +Curves and analytic surfaces are converted into display geometry according to +view and tolerance settings. A coarse display does not necessarily mean the +stored curve or solid is coarse. + +## Parametric Constraints + +### Geometric constraints + +Constraints capture relationships such as coincident, horizontal, vertical, +parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the +degree-of-freedom and conflict indicators to understand the sketch state. + +### Editing constrained geometry + +Moving or grip-editing constrained geometry can move neighboring objects. If a +solve fails or becomes redundant, inspect the constraint glyphs and conflict +status before deleting geometry. + +### Persistence and history limitations + +Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. +Other applications may preserve it without interpreting it. Constraint edits +and geometry changes may currently occupy adjacent history entries; verify the +result after undo, redo, erase, copy, and reopen operations. + +## Commands and Shortcuts + +### Command line + +Enter a command name or alias, then respond to its prompts. Press `Escape` to +cancel the active command and `Return` to accept an available default. Exact +prompt options vary by command and current selection. + +### Discover commands + +Use command autocomplete and the ribbon to discover available operations. Some +commands are one-shot actions; others maintain state across several point, +number, keyword, or object-selection inputs. + +### Shortcut conventions + +Application and operating-system shortcuts can differ between native and web +builds. Avoid assigning browser-reserved shortcuts to web-only workflows. + +## Automation and Plugins + +### Automation interface + +The automation interface can inspect documents, run commands, query geometry, +capture the viewport, and perform batches. Callers should use request IDs and +document revisions to avoid repeating or applying work to stale state. + +### Plugins + +Native plugins can contribute commands and ribbon surfaces through the plugin +host. Install only trusted packages that match the host API, compiler, and CAD +model compatibility requirements. + +### Native and web differences + +The browser build uses in-memory file workflows and cannot load native plugin +libraries. Some rendering, font, windowing, and parallel-processing behavior +also differs from the desktop build. + +## Troubleshooting and Recovery + +### A drawing opens incorrectly + +Check units, extents, layers, layouts, block placement, missing references, and +fonts. Reopen a copy after saving to detect serialization problems early. Keep +the original file available for comparison. + +### Geometry is missing or distorted + +Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block +scale, array settings, clipping boundaries, object normals, and coordinates far +from the origin. + +### Rendering is incomplete + +Reduce unusually large images or material textures, simplify extreme hatch or +array inputs, and compare another visual style. Record the graphics backend and +the smallest drawing that reproduces the issue. + +### Undo did not restore the expected state + +Stop editing and save a diagnostic copy. Try the next adjacent undo entry only +when you understand what it represents. Compare the result with autosave or +backup data before overwriting the original drawing. + +### Report a reproducible problem + +Include the OpenCAD Studio version, platform, native or web build, exact steps, +expected and observed results, and a minimal sanitized drawing when possible. +Do not publish confidential drawings or external-reference paths. + diff --git a/help/parametric-constraints.html b/help/parametric-constraints.html new file mode 100644 index 000000000..3ba9227ee --- /dev/null +++ b/help/parametric-constraints.html @@ -0,0 +1,55 @@ + + + + + + + Parametric Constraints — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Parametric Constraints

+

Geometric constraints

+

Constraints capture relationships such as coincident, horizontal, vertical, parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the degree-of-freedom and conflict indicators to understand the sketch state.

+

Editing constrained geometry

+

Moving or grip-editing constrained geometry can move neighboring objects. If a solve fails or becomes redundant, inspect the constraint glyphs and conflict status before deleting geometry.

+

Persistence and history limitations

+

Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. Other applications may preserve it without interpreting it. Constraint edits and geometry changes may currently occupy adjacent history entries; verify the result after undo, redo, erase, copy, and reopen operations.

+ + +
+
+ + diff --git a/help/search-index.js b/help/search-index.js new file mode 100644 index 000000000..f6ef5f775 --- /dev/null +++ b/help/search-index.js @@ -0,0 +1 @@ +window.OCS_HELP_INDEX = [{"title":"What OpenCAD Studio does","chapter":"Getting Started","url":"getting-started.html#what-opencad-studio-does","text":"What OpenCAD Studio does OpenCAD Studio is a desktop and web CAD application for opening, creating, editing, visualizing, and saving two- and three-dimensional drawings. It works with DWG and DXF drawings and provides command-line, ribbon, automation, and plugin-oriented workflows."},{"title":"A first drawing","chapter":"Getting Started","url":"getting-started.html#a-first-drawing","text":"A first drawing Create a new drawing or open an existing DWG or DXF file. Confirm the active workspace, units, layer, and object-snap settings. Create geometry with a drawing command such as Line , Circle , or Polyline . Edit the result with grips or a modification command. Save the drawing under a new name until its compatibility has been verified. Data safety: Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file."},{"title":"How to use this manual","chapter":"Getting Started","url":"getting-started.html#how-to-use-this-manual","text":"How to use this manual Use the chapter navigation for broad workflows, the search box for a command or concept, and the previous/next links for sequential reading. Command names, system variables, keys, and file extensions appear in monospaced text ."},{"title":"Create and open drawings","chapter":"Files and Drawings","url":"files-drawings.html#create-and-open-drawings","text":"Create and open drawings Use New for a blank document and Open for an existing DWG, DXF, backup, or autosave drawing. After opening a file from another CAD system, inspect its units, layers, blocks, fonts, raster references, layouts, and model extents."},{"title":"Save and Save As","chapter":"Files and Drawings","url":"files-drawings.html#save-and-save-as","text":"Save and Save As Save updates the current drawing. Save As changes its path, format, or version. Prefer Save As when validating interoperability or recovering an older drawing. A successful write does not replace visual inspection of a reopened copy."},{"title":"Recovery and backups","chapter":"Files and Drawings","url":"files-drawings.html#recovery-and-backups","text":"Recovery and backups OpenCAD Studio recognizes drawing backups and autosave-style files where the underlying data is DWG or DXF. If strict loading rejects malformed entities, use the recovery workflow and review what was discarded before continuing work."},{"title":"External resources","chapter":"Files and Drawings","url":"files-drawings.html#external-resources","text":"External resources Raster images, underlays, fonts, materials, and external references may depend on paths outside the drawing. Keep referenced files together when moving a project and verify that relative paths still resolve."},{"title":"Model space and paper space","chapter":"Workspace and Navigation","url":"workspace-navigation.html#model-space-and-paper-space","text":"Model space and paper space Model space is the primary drawing environment. Paper-space layouts arrange views for plotting through layout viewports. Always confirm the active space before drawing or editing."},{"title":"Pan, zoom, and orbit","chapter":"Workspace and Navigation","url":"workspace-navigation.html#pan-zoom-and-orbit","text":"Pan, zoom, and orbit Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. Zoom Extents is useful when the drawing appears empty or geometry is far from the expected location."},{"title":"Multiple model viewports","chapter":"Workspace and Navigation","url":"workspace-navigation.html#multiple-model-viewports","text":"Multiple model viewports Model viewports can show different views of the same document. Grid, snap, camera, and render mode may vary by viewport; confirm which viewport is active before entering coordinates."},{"title":"Selection and object snaps","chapter":"Workspace and Navigation","url":"workspace-navigation.html#selection-and-object-snaps","text":"Selection and object snaps Selection identifies the objects a command will modify. Object snaps target precise geometric locations such as endpoints, midpoints, centers, and intersections. Zoom in and verify the snap marker when precision matters."},{"title":"Basic drawing commands","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#basic-drawing-commands","text":"Basic drawing commands Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, and other supported entities from the ribbon or command line. Follow the active prompt: multi-step commands remain active until they complete or are cancelled."},{"title":"Modify geometry","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#modify-geometry","text":"Modify geometry Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification."},{"title":"Grips and direct manipulation","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#grips-and-direct-manipulation","text":"Grips and direct manipulation Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it."},{"title":"Undo, redo, and OOPS","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#undo-redo-and-oops","text":"Undo, redo, and OOPS Undo reverses recorded document operations and Redo reapplies them. OOPS restores the most recently erased entities without reversing later commands. Save before a risky sequence and verify that associated dimensions, hatches, groups, and constraints were restored with their geometry."},{"title":"Layers","chapter":"Layers and Properties","url":"layers-properties.html#layers","text":"Layers Layers organize geometry and control visibility, locking, color, line type, line weight, and plot behavior. Set the intended active layer before creating objects. Avoid editing objects on locked layers."},{"title":"Object properties","chapter":"Layers and Properties","url":"layers-properties.html#object-properties","text":"Object properties Properties can be assigned explicitly or inherited ByLayer or ByBlock . Use the Properties surface or commands such as CHPROP to inspect and change selected objects."},{"title":"Styles","chapter":"Layers and Properties","url":"layers-properties.html#styles","text":"Styles Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving."},{"title":"Create and insert blocks","chapter":"Blocks and References","url":"blocks-references.html#create-and-insert-blocks","text":"Create and insert blocks A block definition groups reusable drawing content; an INSERT places an instance with its own position, rotation, scale, attributes, and optional array settings. Editing a definition affects every reference to it."},{"title":"Array inserts","chapter":"Blocks and References","url":"blocks-references.html#array-inserts","text":"Array inserts Array inserts place multiple block instances with row and column counts and spacing. Large counts can generate substantial geometry. Validate scaled and rotated arrays when exchanging drawings with another CAD system."},{"title":"Attributes and external references","chapter":"Blocks and References","url":"blocks-references.html#attributes-and-external-references","text":"Attributes and external references Attributes attach editable values to block references. External references and underlays retain links to other files; preserve their paths when sharing or archiving a project."},{"title":"Text and annotation","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#text-and-annotation","text":"Text and annotation Single-line text, multiline text, leaders, multileaders, tables, tolerances, center marks, and centerlines communicate design intent. Missing fonts can change layout or substitute glyphs, especially in the web build."},{"title":"Dimensions","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#dimensions","text":"Dimensions Dimensions combine measured geometry with a dimension style. Associative dimensions update when their source geometry changes; non-associative or partially associated dimensions require manual verification."},{"title":"Annotation scale","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#annotation-scale","text":"Annotation scale Annotative objects can display differently at different viewport scales. Confirm the active annotation scale before creating annotations and inspect all paper-space viewports before plotting."},{"title":"Layouts and viewports","chapter":"Layouts and Plotting","url":"layouts-plotting.html#layouts-and-viewports","text":"Layouts and viewports Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes."},{"title":"Page setup and plot preview","chapter":"Layouts and Plotting","url":"layouts-plotting.html#page-setup-and-plot-preview","text":"Page setup and plot preview Choose the target device or file format, paper size, orientation, plot area, scale, style behavior, and output quality. Use preview to catch clipping, incorrect line weights, hidden layers, or an unintended plot area."},{"title":"Export checks","chapter":"Layouts and Plotting","url":"layouts-plotting.html#export-checks","text":"Export checks Before distributing a PDF or plotted file, compare it with the drawing at the intended sheet size and verify text, dimensions, raster content, transparency, line types, and viewport scales."},{"title":"Solids and surfaces","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#solids-and-surfaces","text":"Solids and surfaces OpenCAD Studio can create and display solid primitives and modeled features such as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for display while exact body data is retained where supported."},{"title":"Visual styles and lighting","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#visual-styles-and-lighting","text":"Visual styles and lighting Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, not the underlying object geometry. Switch to wireframe when diagnosing missing faces or material problems."},{"title":"Tessellation quality","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#tessellation-quality","text":"Tessellation quality Curves and analytic surfaces are converted into display geometry according to view and tolerance settings. A coarse display does not necessarily mean the stored curve or solid is coarse."},{"title":"Geometric constraints","chapter":"Parametric Constraints","url":"parametric-constraints.html#geometric-constraints","text":"Geometric constraints Constraints capture relationships such as coincident, horizontal, vertical, parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the degree-of-freedom and conflict indicators to understand the sketch state."},{"title":"Editing constrained geometry","chapter":"Parametric Constraints","url":"parametric-constraints.html#editing-constrained-geometry","text":"Editing constrained geometry Moving or grip-editing constrained geometry can move neighboring objects. If a solve fails or becomes redundant, inspect the constraint glyphs and conflict status before deleting geometry."},{"title":"Persistence and history limitations","chapter":"Parametric Constraints","url":"parametric-constraints.html#persistence-and-history-limitations","text":"Persistence and history limitations Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. Other applications may preserve it without interpreting it. Constraint edits and geometry changes may currently occupy adjacent history entries; verify the result after undo, redo, erase, copy, and reopen operations."},{"title":"Command line","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#command-line","text":"Command line Enter a command name or alias, then respond to its prompts. Press Escape to cancel the active command and Return to accept an available default. Exact prompt options vary by command and current selection."},{"title":"Discover commands","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#discover-commands","text":"Discover commands Use command autocomplete and the ribbon to discover available operations. Some commands are one-shot actions; others maintain state across several point, number, keyword, or object-selection inputs."},{"title":"Shortcut conventions","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#shortcut-conventions","text":"Shortcut conventions Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows."},{"title":"Automation interface","chapter":"Automation and Plugins","url":"automation-plugins.html#automation-interface","text":"Automation interface The automation interface can inspect documents, run commands, query geometry, capture the viewport, and perform batches. Callers should use request IDs and document revisions to avoid repeating or applying work to stale state."},{"title":"Plugins","chapter":"Automation and Plugins","url":"automation-plugins.html#plugins","text":"Plugins Native plugins can contribute commands and ribbon surfaces through the plugin host. Install only trusted packages that match the host API, compiler, and CAD model compatibility requirements."},{"title":"Native and web differences","chapter":"Automation and Plugins","url":"automation-plugins.html#native-and-web-differences","text":"Native and web differences The browser build uses in-memory file workflows and cannot load native plugin libraries. Some rendering, font, windowing, and parallel-processing behavior also differs from the desktop build."},{"title":"A drawing opens incorrectly","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#a-drawing-opens-incorrectly","text":"A drawing opens incorrectly Check units, extents, layers, layouts, block placement, missing references, and fonts. Reopen a copy after saving to detect serialization problems early. Keep the original file available for comparison."},{"title":"Geometry is missing or distorted","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#geometry-is-missing-or-distorted","text":"Geometry is missing or distorted Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block scale, array settings, clipping boundaries, object normals, and coordinates far from the origin."},{"title":"Rendering is incomplete","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#rendering-is-incomplete","text":"Rendering is incomplete Reduce unusually large images or material textures, simplify extreme hatch or array inputs, and compare another visual style. Record the graphics backend and the smallest drawing that reproduces the issue."},{"title":"Undo did not restore the expected state","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#undo-did-not-restore-the-expected-state","text":"Undo did not restore the expected state Stop editing and save a diagnostic copy. Try the next adjacent undo entry only when you understand what it represents. Compare the result with autosave or backup data before overwriting the original drawing."},{"title":"Report a reproducible problem","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#report-a-reproducible-problem","text":"Report a reproducible problem Include the OpenCAD Studio version, platform, native or web build, exact steps, expected and observed results, and a minimal sanitized drawing when possible. Do not publish confidential drawings or external-reference paths."}]; diff --git a/help/tools/build_help.py b/help/tools/build_help.py new file mode 100644 index 000000000..7a99dff2e --- /dev/null +++ b/help/tools/build_help.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Build the static OpenCAD Studio HTML help system from help/manual.md.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from html.parser import HTMLParser +from pathlib import Path + + +HELP = Path(__file__).resolve().parents[1] +SOURCE = HELP / "manual.md" + + +@dataclass(frozen=True) +class Page: + filename: str + title: str + summary: str + + +PAGES = ( + Page("getting-started.html", "Getting Started", "Begin a drawing safely and learn how to use this manual."), + Page("files-drawings.html", "Files and Drawings", "Open, save, recover, and move drawings with their dependencies."), + Page("workspace-navigation.html", "Workspace and Navigation", "Work with spaces, views, selection, and object snaps."), + Page("create-edit-geometry.html", "Create and Edit Geometry", "Draw, modify, grip-edit, erase, and restore geometry."), + Page("layers-properties.html", "Layers and Properties", "Organize objects and control their inherited appearance."), + Page("blocks-references.html", "Blocks and References", "Create reusable content, arrays, attributes, and file references."), + Page("annotation-dimensions.html", "Annotation and Dimensions", "Add readable text, dimensions, leaders, and scaled annotation."), + Page("layouts-plotting.html", "Layouts and Plotting", "Prepare paper-space sheets and verify plotted output."), + Page("3d-modeling.html", "3D Modeling and Visualization", "Create and inspect solids, surfaces, materials, and visual styles."), + Page("parametric-constraints.html", "Parametric Constraints", "Capture geometric intent and diagnose solve or history issues."), + Page("commands-shortcuts.html", "Commands and Shortcuts", "Use prompts, autocomplete, aliases, and keyboard conventions."), + Page("automation-plugins.html", "Automation and Plugins", "Control drawings programmatically and extend the native app."), + Page("troubleshooting-recovery.html", "Troubleshooting and Recovery", "Diagnose file, geometry, rendering, and history problems."), +) + + +def slug(text: str) -> str: + value = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE) + value = re.sub(r"[\s_]+", "-", value) + return value.strip("-") + + +def split_chapters(markdown: str) -> dict[str, str]: + chapters: dict[str, list[str]] = {} + current: list[str] | None = None + for line in markdown.splitlines(): + match = re.match(r"^## (.+)$", line) + if match: + title = match.group(1).strip() + if title in chapters: + raise RuntimeError(f"Duplicate chapter: {title}") + current = [line] + chapters[title] = current + elif current is not None: + current.append(line) + return {title: "\n".join(lines) for title, lines in chapters.items()} + + +def pandoc(markdown: str) -> str: + process = subprocess.run( + ["pandoc", "--from=gfm", "--to=html5", "--wrap=none"], + input=markdown, + text=True, + capture_output=True, + check=True, + ) + return process.stdout.strip() + + +def headings(markdown: str) -> list[tuple[str, str]]: + output: list[tuple[str, str]] = [] + for line in markdown.splitlines(): + match = re.match(r"^### (.+)$", line) + if match: + title = match.group(1).strip() + output.append((slug(title), title)) + return output + + +def sidebar(current: Page | None, chapters: dict[str, str]) -> str: + parts = ['") + return "\n".join(parts) + + +def overview() -> str: + cards = "".join( + f'{html.escape(page.title)}' + f'{html.escape(page.summary)}' + for page in PAGES + ) + return f""" +
+

OpenCAD Studio User Manual

+

Find the workflow, command, or concept you need

+

Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.

+
+ +

Manual chapters

+
{cards}
+""".strip() + + +def shell(page: Page | None, title: str, body: str, chapters: dict[str, str]) -> str: + previous_link = "" + next_link = "" + if page is not None: + index = PAGES.index(page) + previous = PAGES[index - 1] if index else None + following = PAGES[index + 1] if index + 1 < len(PAGES) else None + previous_link = ( + f'' + if previous else '' + ) + next_link = ( + f'' + if following else "" + ) + elif PAGES: + next_link = f'' + + return f""" + + + + + + {html.escape(title)} — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ {sidebar(page, chapters)} +
+
+

OpenCAD Studio User Manual

+

{html.escape(title)}

+ {body} + + +
+
+ + +""" + + +class TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + if data.strip(): + self.parts.append(data.strip()) + + +def search_entries(chapters: dict[str, str]) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for page in PAGES: + chapter = chapters[page.title] + sections = re.split(r"(?=^### )", chapter, flags=re.MULTILINE) + for section in sections: + match = re.match(r"^### (.+)$", section.splitlines()[0] if section.splitlines() else "") + if not match: + continue + title = match.group(1).strip() + extractor = TextExtractor() + extractor.feed(pandoc(section)) + entries.append({ + "title": title, + "chapter": page.title, + "url": f"{page.filename}#{slug(title)}", + "text": " ".join(extractor.parts), + }) + return entries + + +def generated_outputs() -> dict[str, str]: + markdown = SOURCE.read_text(encoding="utf-8") + chapters = split_chapters(markdown) + expected = {page.title for page in PAGES} + if set(chapters) != expected: + raise RuntimeError( + f"Manual chapter mapping mismatch. Missing: {sorted(expected - set(chapters))}; " + f"extra: {sorted(set(chapters) - expected)}" + ) + + output = {"index.html": shell(None, "Help overview", overview(), chapters)} + for page in PAGES: + chapter_body = "\n".join(chapters[page.title].splitlines()[1:]).lstrip() + output[page.filename] = shell(page, page.title, pandoc(chapter_body), chapters) + index_json = json.dumps(search_entries(chapters), ensure_ascii=False, separators=(",", ":")) + output["search-index.js"] = f"window.OCS_HELP_INDEX = {index_json};\n" + return output + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="Fail if generated output is stale.") + args = parser.parse_args() + output = generated_outputs() + + if args.check: + stale = [name for name, content in output.items() if not (HELP / name).is_file() or (HELP / name).read_text(encoding="utf-8") != content] + if stale: + print("Stale generated help: " + ", ".join(stale), file=sys.stderr) + return 1 + print(f"Generated help is current ({len(output) - 1} pages plus search index).") + return 0 + + for name, content in output.items(): + (HELP / name).write_text(content, encoding="utf-8") + print(f"Generated {len(output) - 1} pages and search-index.js in {HELP}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/help/tools/check_help.py b/help/tools/check_help.py new file mode 100644 index 000000000..9d1eb42f2 --- /dev/null +++ b/help/tools/check_help.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Validate local links, anchors, assets, context targets, and basic HTML structure.""" + +from __future__ import annotations + +import json +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote + + +HELP = Path(__file__).resolve().parents[1] + + +class Document(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.links: list[str] = [] + self.assets: list[str] = [] + self.has_main = False + self.has_title = False + self.images_without_alt = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if values.get("id"): + self.ids.add(values["id"] or "") + if tag == "a" and values.get("href"): + self.links.append(values["href"] or "") + if tag in {"img", "script", "link"}: + target = values.get("src") or values.get("href") + if target: + self.assets.append(target) + if tag == "img" and "alt" not in values: + self.images_without_alt += 1 + self.has_main |= tag == "main" + self.has_title |= tag == "title" + + +def load_documents() -> dict[str, Document]: + documents: dict[str, Document] = {} + for path in sorted(HELP.glob("*.html")): + document = Document() + document.feed(path.read_text(encoding="utf-8")) + documents[path.name] = document + return documents + + +def validate_target(source: str, target: str, documents: dict[str, Document], errors: list[str]) -> None: + if target.startswith(("http:", "https:", "mailto:", "javascript:")): + return + filename, _, fragment = target.partition("#") + filename = unquote(filename) or source + destination = HELP / filename + if not destination.is_file(): + errors.append(f"{source}: missing target {target}") + return + if fragment and destination.suffix == ".html": + document = documents.get(destination.name) + if document is None or fragment not in document.ids: + errors.append(f"{source}: missing anchor {target}") + + +def main() -> int: + documents = load_documents() + errors: list[str] = [] + if not documents: + errors.append("No generated HTML pages found.") + + for name, document in documents.items(): + if not document.has_main: + errors.append(f"{name}: missing
") + if not document.has_title: + errors.append(f"{name}: missing ") + if document.images_without_alt: + errors.append(f"{name}: {document.images_without_alt} image(s) missing alt") + for target in document.links + document.assets: + validate_target(name, target, documents, errors) + + context = json.loads((HELP / "context-map.json").read_text(encoding="utf-8")) + for group in ("commands", "surfaces"): + for key, target in context.get(group, {}).items(): + validate_target(f"context-map.json:{group}.{key}", target, documents, errors) + + if errors: + print("Help validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + links = sum(len(document.links) for document in documents.values()) + print(f"Validated {len(documents)} pages, {links} links, and {sum(len(context.get(group, {})) for group in ('commands', 'surfaces'))} context targets.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/help/troubleshooting-recovery.html b/help/troubleshooting-recovery.html new file mode 100644 index 000000000..ea998bcfd --- /dev/null +++ b/help/troubleshooting-recovery.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="description" content="OpenCAD Studio user manual — Troubleshooting and Recovery"> + <title>Troubleshooting and Recovery — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Troubleshooting and Recovery

+

A drawing opens incorrectly

+

Check units, extents, layers, layouts, block placement, missing references, and fonts. Reopen a copy after saving to detect serialization problems early. Keep the original file available for comparison.

+

Geometry is missing or distorted

+

Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block scale, array settings, clipping boundaries, object normals, and coordinates far from the origin.

+

Rendering is incomplete

+

Reduce unusually large images or material textures, simplify extreme hatch or array inputs, and compare another visual style. Record the graphics backend and the smallest drawing that reproduces the issue.

+

Undo did not restore the expected state

+

Stop editing and save a diagnostic copy. Try the next adjacent undo entry only when you understand what it represents. Compare the result with autosave or backup data before overwriting the original drawing.

+

Report a reproducible problem

+

Include the OpenCAD Studio version, platform, native or web build, exact steps, expected and observed results, and a minimal sanitized drawing when possible. Do not publish confidential drawings or external-reference paths.

+ + +
+
+ + diff --git a/help/workspace-navigation.html b/help/workspace-navigation.html new file mode 100644 index 000000000..ff8e00628 --- /dev/null +++ b/help/workspace-navigation.html @@ -0,0 +1,57 @@ + + + + + + + Workspace and Navigation — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Workspace and Navigation

+

Model space and paper space

+

Model space is the primary drawing environment. Paper-space layouts arrange views for plotting through layout viewports. Always confirm the active space before drawing or editing.

+

Pan, zoom, and orbit

+

Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. Zoom Extents is useful when the drawing appears empty or geometry is far from the expected location.

+

Multiple model viewports

+

Model viewports can show different views of the same document. Grid, snap, camera, and render mode may vary by viewport; confirm which viewport is active before entering coordinates.

+

Selection and object snaps

+

Selection identifies the objects a command will modify. Object snaps target precise geometric locations such as endpoints, midpoints, centers, and intersections. Zoom in and verify the snap marker when precision matters.

+ + +
+
+ + diff --git a/src/app/automation.rs b/src/app/automation.rs index 743f05770..1343081c4 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -302,6 +302,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); self.tabs[i].scene.sketch_constraints.clear(); + self.tabs[i].scene.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which @@ -331,6 +332,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.document = doc; self.tabs[i].scene.load_sketch_constraints_from_document(); + self.tabs[i].scene.load_named_parameters_from_document(); self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, @@ -1063,6 +1065,7 @@ mod tests { "CHANGELOG", "CUI", "ALIASEDIT", + "OPTIONS", ]; for cmd in standalone { assert!( diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index eee1345cd..a455b7a0f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -951,6 +951,81 @@ impl OpenCADStudio { self.push_sketch_constraints_history(i, label, scope, constraints_before); } + /// PARAMETERS' Apply button (`docs/named_parameters_design.md` stage 4): + /// rebuilds the active tab's `Scene::named_parameters` from the + /// editor's working buffer, reports any row that failed to validate via + /// the command line, and re-solves every scope with a constraint driven + /// by a named parameter — the entire point of a *named* parameter is + /// that editing it ripples to whatever references it, the same way + /// editing a constraint's literal value already does. + /// + /// **Deliberate scope-down, matching this project's established + /// treatment of undo-granularity questions** (design doc §5 open + /// question 3, and the sibling constraint-system project's stage 9): the + /// geometry this ripple moves is bracketed into one undo step below + /// (same `begin_undo`/`bump_entities`/`commit_undo_delta` pattern + /// `resolve_one_sketch_conflict` above uses), but the parameter *table* + /// edit itself has no undo entry of its own yet — that would need a new + /// `HistorySnapshot` variant mirroring `SketchConstraints` + /// (`push_sketch_constraints_history`). Left for a follow-up, not + /// silently dropped: undoing after an Apply undoes the geometry it + /// moved, not the parameter values themselves. + pub(super) fn apply_named_parameter_editor_rows(&mut self) { + let i = self.active_tab; + // A name shared by more than one row can't be resolved by picking + // one arbitrarily (`ParameterTable::set` would just let the last + // one silently win) -- refuse every row sharing that name up front, + // same as `named_parameters::preview`'s live check. + let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows); + let mut table = crate::scene::named_parameters::ParameterTable::new(); + let mut failed = 0usize; + for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); + failed += 1; + } + } + if !duplicates.is_empty() { + self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref()); + failed += duplicates.len(); + } + let param_count = table.len(); + self.tabs[i].scene.named_parameters = table; + + // The whole table just changed, not one isolated value, so there is + // no cheaper "which handles actually moved" test worth doing here — + // re-solve every scope with a constraint driven by *any* named + // parameter (mirrors `solve_scope`'s own "full rebuild, not + // incremental" philosophy). + let touched: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_)))) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + + if !touched.is_empty() { + let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = + touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + self.tabs[i].dirty = true; + if failed == 0 { + self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); + } + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -6075,6 +6150,93 @@ mod sketch_constraint_undo_tests { assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); } + /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's + /// working buffer both defines the parameter and re-solves whatever + /// constraint already references it by name — the entire point of a + /// *named* parameter, not just data plumbing. + #[test] + fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + + // The constraint is added *before* the parameter exists -- allowed, + // same as `ParameterTable::set`'s own forward-reference tolerance; + // it just doesn't resolve to anything until the parameter is defined. + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Distance, + refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())), + label: "Distance constraint", + }); + + app.named_parameter_editor_rows = + vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }]; + app.apply_named_parameter_editor_rows(); + + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}"); + + // Redefining the parameter and re-applying should ripple again. + app.named_parameter_editor_rows[0].formula = "3".to_string(); + app.apply_named_parameter_editor_rows(); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + } + + /// A row that fails to validate (here, a formula that doesn't parse) + /// must not silently vanish or corrupt the table: it's reported as an + /// error and simply isn't added, while a valid row alongside it still + /// commits — the same isolate-the-failure treatment + /// `ParameterTable::resolve_all` already gives a bad reference. + #[test] + fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() }, + ]; + app.apply_named_parameter_editor_rows(); + + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0)); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added"); + assert!( + app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error), + "a malformed row must be reported as an error, not silently dropped" + ); + } + + /// Two rows typed with the same name must not silently collapse into + /// "whichever `set` call happens last wins" — both are refused, and an + /// unrelated row still commits normally. + #[test] + fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() }, + ]; + app.apply_named_parameter_editor_rows(); + + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all"); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0)); + assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); + } + /// Design doc §6.1's manual Coincident UI: two picks landing on two /// different lines' endpoints should resolve to a real `SketchRef` pair /// and add a Coincident constraint — the same `AddCoincidentConstraint` diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index b69daec39..a5ba30972 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -292,6 +292,8 @@ pub fn start_allowed(cmd: &str) -> bool { | "PERF" | "CUI" | "ALIASEDIT" + | "OPTIONS" + | "OP" | "CUILOAD" | "CUIIMPORT" ) @@ -312,6 +314,7 @@ inventory::submit!(crate::command::CommandRegistration { "CLEANSCREEN", "CUI", "DSETTINGS", + "PARAMETERS", "GRID", "ISODRAFT", "ISOPLANE", diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index a181b677b..6ceb6d67a 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -240,6 +240,25 @@ impl OpenCADStudio { return Some(Task::done(Message::AliasEditorOpen)); } + // ── OPTIONS — app preferences dialog ──────────────────────────── + // Registered for autocomplete (`commands/mod.rs`'s "one-shot + // commands" list) but had no actual dispatch arm anywhere — the + // Start-tab welcome screen's "Options" button emits + // `Message::OptionsOpen` directly and was the only way to reach + // it. Found while live-testing the new UI-scale setting (nothing + // to do with that feature itself, just discovered along the + // way). "OP" is AutoCAD's own short alias for this command. + "OPTIONS" | "OP" => { + return Some(Task::done(Message::OptionsOpen)); + } + + // ── PARAMETERS — named-parameter/expression table editor ─────── + // `docs/named_parameters_design.md` stage 4. AutoCAD's own + // command name for the equivalent feature (Parameters Manager). + "PARAMETERS" => { + return Some(Task::done(Message::NamedParametersOpen)); + } + // ── CUI — keyboard shortcut / key-binding editor ─────────────── "CUI" => { return Some(Task::done(Message::ShortcutsPanelOpen)); diff --git a/src/app/mod.rs b/src/app/mod.rs index 8dbe6b889..b7723545f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -504,6 +504,12 @@ pub(super) struct OpenCADStudio { crosshair_color_input: String, /// Model-space lineweight preview scale, in percent (25..=200). lineweight_display_scale: i32, + /// Whole-interface scale, in percent (50..=200) — drives iced's own + /// per-window `scale_factor` (`run`'s `.scale_factor(...)` hook), so it + /// resizes every panel, dialog, the ribbon, and their text uniformly + /// rather than needing a separate font-size knob threaded through each + /// panel's own view code. + ui_scale: i32, /// Isometric drafting state and active axis pair. isometric_drafting: bool, iso_plane: settings::IsoPlane, @@ -1018,6 +1024,15 @@ pub(super) struct OpenCADStudio { /// edited. Seeded from `command_aliases` on open, committed back on close. alias_editor_rows: Vec<(String, String)>, + // ── Named Parameters (docs/named_parameters_design.md, stage 4) ──────── + /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`, + /// this isn't a copy of a separate app-level store — the real state + /// lives per-document at `Scene::named_parameters`; this buffer is + /// seeded from the active tab's table on open and only written back to + /// it on Apply (`apply_named_parameter_editor_rows`, + /// `src/app/named_parameters.rs`). + named_parameter_editor_rows: Vec, + // ── Layout Manager Panel ────────────────────────────────────────────── layout_manager_selected: String, layout_manager_rename_buf: String, @@ -1695,6 +1710,7 @@ pub enum ModalKind { AttributeEditor, LayerDeleteWarning, Aliases, + NamedParameters, ScaleManager, /// Add / remove the annotation scales a single selected object has a /// per-object representation for. @@ -1937,6 +1953,8 @@ pub enum Message { CursorTypeChanged(settings::CursorType), /// Set the model-space lineweight preview scale from Options. LineweightDisplayScaleChanged(i32), + /// Set the whole-interface scale (`ui_scale`) from Options. + UiScaleChanged(i32), /// Set the ribbon's large-button icon-label font size from Options. RibbonLabelFontSizeChanged(i32), /// Set the ribbon's panel-title ("Draw", "Modify", …) font size from Options. @@ -2706,6 +2724,24 @@ pub enum Message { AliasEditorRemove(usize), /// Commit the edited rows to the alias table (Apply button); stays open. AliasEditorApply, + // ── Named Parameters (PARAMETERS) ─────────────────────────────────── + /// Open the named-parameter editor, seeding rows from the active tab's + /// `Scene::named_parameters`. + NamedParametersOpen, + /// Live edit of the name or formula in row `idx`. + NamedParametersInput { + idx: usize, + field: crate::ui::window::named_parameters::ParamField, + value: String, + }, + /// Append a blank parameter row. + NamedParametersAdd, + /// Remove parameter row `idx`. + NamedParametersRemove(usize), + /// Commit the edited rows to `Scene::named_parameters` (Apply button) + /// and re-solve every constraint that reads a named parameter; stays + /// open. + NamedParametersApply, // ── About window ──────────────────────────────────────────────────── AboutOpen, /// Close whatever in-canvas modal dialog is open (Plan B). @@ -3378,6 +3414,7 @@ impl OpenCADStudio { crosshair_color: None, crosshair_color_input: String::new(), lineweight_display_scale: 100, + ui_scale: 100, isometric_drafting: false, iso_plane: settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -3579,6 +3616,7 @@ impl OpenCADStudio { // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), + named_parameter_editor_rows: Vec::new(), // Layout Manager layout_manager_selected: "Model".to_string(), layer_state_selected: None, @@ -3978,6 +4016,7 @@ pub fn run() -> iced::Result { ..iced::Settings::default() }) .subscription(OpenCADStudio::subscription) + .scale_factor(|state: &OpenCADStudio, _window: window::Id| state.ui_scale as f32 / 100.0) .title(|state: &OpenCADStudio, window_id: window::Id| { let _ = window_id; // all dialogs are in-canvas modals now if let Some(tab) = state.tabs.get(state.active_tab) { @@ -4022,6 +4061,7 @@ pub fn run_web() -> iced::Result { OpenCADStudio::view_main, ) .subscription(OpenCADStudio::subscription) + .scale_factor(|state: &OpenCADStudio| state.ui_scale as f32 / 100.0) .title(|_state: &OpenCADStudio| { concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() }) diff --git a/src/app/settings.rs b/src/app/settings.rs index a9ca0cb55..589555127 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -166,6 +166,14 @@ pub struct UserSettings { pub crosshair_color: Option<[u8; 3]>, /// Model-space lineweight preview scale as a percentage. pub lineweight_display_scale: i32, + /// Whole-interface scale, as a percentage — every panel, dialog, the + /// ribbon, and their text, not just one widget's font size. Wired + /// straight into iced's own per-window `scale_factor` hook + /// (`OpenCADStudio::run`), so a change takes effect immediately with no + /// per-panel plumbing needed. Relies on the struct-level `#[serde(default)]` + /// (via `impl Default` below) to backfill this field for a config file + /// saved before this setting existed, same as every other field here. + pub ui_scale: i32, /// Isometric drafting changes the grid and crosshair to the active axis pair. pub isometric_drafting: bool, pub iso_plane: IsoPlane, @@ -310,6 +318,7 @@ impl Default for UserSettings { cursor_type: CursorType::Crosshair, crosshair_color: None, lineweight_display_scale: 100, + ui_scale: 100, isometric_drafting: false, iso_plane: IsoPlane::Left, snap_angle_deg: 0.0, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 9b2dd763b..8a9640335 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -368,6 +368,7 @@ impl OpenCADStudio { cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, + ui_scale: self.ui_scale, isometric_drafting: self.isometric_drafting, iso_plane: self.iso_plane, snap_angle_deg: self.snap_angle_deg, @@ -425,6 +426,7 @@ impl OpenCADStudio { .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200); + self.ui_scale = s.ui_scale.clamp(50, 200); self.isometric_drafting = s.isometric_drafting; self.iso_plane = s.iso_plane; self.snap_angle_deg = if s.snap_angle_deg.is_finite() { @@ -1394,6 +1396,9 @@ pub(super) fn on_open_file(&mut self) -> Task { // Design doc §8 stage 4: read back any persisted sketch // constraint sets right after the document is installed. self.tabs[i].scene.load_sketch_constraints_from_document(); + // named_parameters_design.md stage 2: same load-time hook, + // for the document-wide parameter table. + self.tabs[i].scene.load_named_parameters_from_document(); self.tabs[i].active_layer = self.tabs[i] .scene .document @@ -1660,6 +1665,9 @@ pub(super) fn on_open_file(&mut self) -> Task { // Design doc §8 stage 4: write any sketch constraint sets into the // document right before it's serialized. self.tabs[i].scene.materialize_sketch_constraints_for_save(); + // named_parameters_design.md stage 2: same save-time hook, for the + // document-wide parameter table. + self.tabs[i].scene.materialize_named_parameters_for_save(); } #[cfg(not(target_arch = "wasm32"))] @@ -2611,6 +2619,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.stamp_header_sysvars(i); self.sync_solid_models_for_save(i); self.tabs[i].scene.materialize_sketch_constraints_for_save(); + self.tabs[i].scene.materialize_named_parameters_for_save(); let tab_id = self.tabs[i].id; let bounds = crate::ui::wrap_bar::dropdown_bounds( crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b076c18a0..76cca25dc 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -5861,6 +5861,43 @@ impl OpenCADStudio { Task::none() } + // ── Named Parameters (PARAMETERS) ───────────────────────────────── + Message::NamedParametersOpen => { + let i = self.active_tab; + self.named_parameter_editor_rows = self.tabs[i] + .scene + .named_parameters() + .iter() + .map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() }) + .collect(); + self.active_modal = Some(super::ModalKind::NamedParameters); + Task::none() + } + Message::NamedParametersInput { idx, field, value } => { + use crate::ui::window::named_parameters::ParamField; + if let Some(row) = self.named_parameter_editor_rows.get_mut(idx) { + match field { + ParamField::Name => row.name = value, + ParamField::Formula => row.formula = value, + } + } + Task::none() + } + Message::NamedParametersAdd => { + self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default()); + Task::none() + } + Message::NamedParametersRemove(idx) => { + if idx < self.named_parameter_editor_rows.len() { + self.named_parameter_editor_rows.remove(idx); + } + Task::none() + } + Message::NamedParametersApply => { + self.apply_named_parameter_editor_rows(); + Task::none() + } + // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { self.active_modal = Some(super::ModalKind::Options); @@ -5900,6 +5937,12 @@ impl OpenCADStudio { Task::none() } + Message::UiScaleChanged(value) => { + self.ui_scale = value.clamp(50, 200); + self.persist_settings_if_changed(); + Task::none() + } + Message::RibbonLabelFontSizeChanged(value) => { self.ribbon.set_label_font_size(value); self.save_config(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index bc0c3bdf7..0d921a8c7 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -13,6 +13,7 @@ impl OpenCADStudio { Some(K::About) => crate::tr!("modal", "about"), Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"), Some(K::Aliases) => crate::tr!("modal", "command-aliases"), + Some(K::NamedParameters) => crate::t!("Named Parameters").into_owned(), Some(K::Options) => crate::tr!("action", "options"), Some(K::FindReplace) => crate::tr!("modal", "find-replace"), Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"), @@ -173,6 +174,19 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::NamedParameters => { + sized_flow( + ex, + 620, + 520, + |flow| { + crate::ui::window::named_parameters::view_window( + &self.named_parameter_editor_rows, + flow, + ) + }, + ) + } super::super::ModalKind::Options => sized_flow( ex, 540, @@ -191,6 +205,7 @@ impl OpenCADStudio { self.crosshair_color, &self.crosshair_color_input, self.lineweight_display_scale, + self.ui_scale, self.ribbon.label_font_size(), self.ribbon.group_title_font_size(), &self.model_space, diff --git a/src/command.rs b/src/command.rs index 771a0b58a..13a109e59 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1336,9 +1336,14 @@ pub enum CmdResult { AddSketchConstraint { kind: crate::scene::sketch_constraints::ConstraintKind, refs: Vec, - /// The typed target for a dimensional kind (Distance/Angle/Radius); - /// `None` for a purely geometric one. - driving_param: Option, + /// The typed target for a dimensional kind (Distance/Angle/Radius) — + /// a literal number, or a named-parameter reference + /// (`docs/named_parameters_design.md` stage 4, recognized by + /// `DistanceConstraintCommand`/`AngleConstraintCommand:: + /// on_text_input` when the typed token matches a known parameter + /// name instead of parsing as a number); `None` for a purely + /// geometric kind. + driving_param: Option, /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index 78d328659..fa0afa064 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -19,9 +19,30 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::named_parameters::DrivingValue; use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::Scene; +/// Recognizes a typed prompt token as either a numeric literal or a +/// reference to an existing named parameter (`docs/ +/// named_parameters_design.md` stage 4's "a way to pick a named parameter +/// instead of typing a literal" — the same prompt takes either, mirroring +/// how AutoCAD's own dimensional-constraint prompts accept an expression in +/// place of a bare value). `known_names` is a one-time snapshot taken at +/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand +/// ::new`, which already has `&Scene`) since `on_text_input` itself has no +/// document access — the same constraint `default_value` already works +/// around. Only *existence* is checked here; the actual value is resolved +/// fresh at solve time (`sketch_solve::build_constraint`), consistent with +/// this project's "no incremental/cached resolution" approach throughout. +fn parse_driving_value(text: &str, known_names: &[String]) -> Option { + let text = text.trim(); + if let Ok(value) = text.parse::() { + return Some(DrivingValue::Literal(value)); + } + known_names.iter().find(|n| n.as_str() == text).map(|n| DrivingValue::Named(n.clone())) +} + pub mod distance_tool { use super::*; pub fn tool() -> ToolDef { @@ -55,6 +76,9 @@ pub struct DistanceConstraintCommand { /// split between line-length (`Distance`) and circle-radius (`Radius`). is_circle: bool, default_value: f64, + /// Snapshot of `Scene::named_parameters`' names at construction time — + /// see `parse_driving_value`'s doc comment for why a snapshot. + known_param_names: Vec, } impl DistanceConstraintCommand { @@ -70,12 +94,15 @@ impl DistanceConstraintCommand { EntityType::Circle(c) => (true, c.radius), _ => return None, }; - Some(Self { handle, is_circle, default_value }) + let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); + Some(Self { handle, is_circle, default_value, known_param_names }) } - fn build(&self, target: f64) -> Option { - if target <= 0.0 { - return None; + fn build(&self, target: DrivingValue) -> Option { + if let DrivingValue::Literal(v) = target { + if v <= 0.0 { + return None; + } } let (kind, refs, label) = if self.is_circle { (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint") @@ -108,11 +135,11 @@ impl CadCommand for DistanceConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(self.default_value).unwrap_or(CmdResult::Cancel) + self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { - let value: f64 = text.trim().parse().ok()?; + let value = parse_driving_value(text, &self.known_param_names)?; self.build(value) } @@ -126,6 +153,9 @@ pub struct AngleConstraintCommand { fixed_handle: Handle, moving_handle: Handle, default_value: f64, + /// Snapshot of `Scene::named_parameters`' names at construction time — + /// see `parse_driving_value`'s doc comment for why a snapshot. + known_param_names: Vec, } impl AngleConstraintCommand { @@ -139,14 +169,15 @@ impl AngleConstraintCommand { let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x); let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x); let default_value = (a2 - a1).to_degrees(); - Some(Self { fixed_handle: fixed, moving_handle: moving, default_value }) + let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); + Some(Self { fixed_handle: fixed, moving_handle: moving, default_value, known_param_names }) } - fn build(&self, target_degrees: f64) -> Option { + fn build(&self, target: DrivingValue) -> Option { Some(CmdResult::AddSketchConstraint { kind: ConstraintKind::Angle, refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)], - driving_param: Some(target_degrees), + driving_param: Some(target), label: "Angle constraint", }) } @@ -170,11 +201,11 @@ impl CadCommand for AngleConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(self.default_value).unwrap_or(CmdResult::Cancel) + self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { - let value: f64 = text.trim().parse().ok()?; + let value = parse_driving_value(text, &self.known_param_names)?; self.build(value) } @@ -182,3 +213,30 @@ impl CadCommand for AngleConstraintCommand { CmdResult::Cancel } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_driving_value_prefers_a_numeric_literal() { + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("12.5", &known), Some(DrivingValue::Literal(12.5))); + // Distance's own `build` rejects a non-positive target later; the + // parse step itself accepts any number, negative included. + assert_eq!(parse_driving_value("-3", &known), Some(DrivingValue::Literal(-3.0))); + } + + #[test] + fn parse_driving_value_recognizes_a_known_parameter_name() { + let known = vec!["hole_dia".to_string(), "plate_len".to_string()]; + assert_eq!(parse_driving_value("hole_dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + } + + #[test] + fn parse_driving_value_rejects_an_unknown_token() { + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("bogus", &known), None); + assert_eq!(parse_driving_value("", &known), None); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5d86346ee..8fbbfac1e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -36,6 +36,8 @@ mod group_layer; mod layout; mod limits; mod modify; +pub mod named_parameters; +mod named_parameters_persist; mod mspace; mod page_setup; mod paper; @@ -1934,6 +1936,17 @@ pub struct Scene { /// touches directly, materialized into `document.objects` only right /// before a save and read back right after a load. pub(crate) sketch_constraints: Vec, + /// Document-wide named-parameter/expression table (`docs/ + /// named_parameters_design.md`). One table per document (not per-scope, + /// unlike `sketch_constraints` — a parameter is meant to be shared + /// across the whole drawing), lives on `Scene` for the same reason + /// `sketch_constraints` does: co-located with the `document: CadDocument` + /// it's synced against. Synced with a single document-level + /// `OCS_NAMED_PARAMETERS` XRecord (hung off `document.header. + /// named_objects_dict_handle`, not a per-`BlockRecord` one) around + /// open/save (`named_parameters_persist.rs`, same "lazy" model as + /// `sketch_persist.rs`). + pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours /// differently; retaining both variants prevents a full block rebuild on @@ -2167,6 +2180,7 @@ impl Scene { dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), sketch_constraints: Vec::new(), + named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), entity_index_cache: RefCell::new(None), diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs new file mode 100644 index 000000000..3168b81cd --- /dev/null +++ b/src/scene/named_parameters.rs @@ -0,0 +1,898 @@ +//! Named, formula-capable document parameters — data model. +//! +//! Design: `docs/named_parameters_design.md`. This is stage 1 of that doc's +//! §4 staged plan: the parameter table type plus a small expression +//! parser/evaluator, unit-tested in isolation — no document/UI integration +//! yet (that's stage 2's XRecord persistence, mirroring +//! [`super::sketch_persist`], and stage 3's +//! `SketchConstraint::driving_param` wiring). +//! +//! A [`Parameter`] is a name plus a formula: either a bare literal (`12.5`) +//! or an expression referencing other parameters by name (`2 * hole_dia + +//! 1.5`). A [`ParameterTable`] holds every parameter for one document (per +//! the design doc's §2: one table per document, not per-[`super:: +//! sketch_constraints::SketchScope`]) and resolves each one's numeric value, +//! rejecting a circular reference at the moment it would be introduced +//! rather than at evaluation time — the design doc's explicit requirement +//! ("A referencing B referencing A must be rejected, not silently +//! infinite-loop or panic"), and the same real-time-rejection behavior a +//! spreadsheet gives for a circular formula. +//! +//! No existing workspace dependency parses expressions (the design doc's §2 +//! flags checking `evalexpr`/`meval` before hand-rolling one). Hand-rolled +//! here instead: cycle detection needs the set of names an expression +//! references *before* it's evaluated, which means owning the parse tree — +//! treating evaluation as a black box, the way a generic expression-eval +//! crate is normally used, would just mean re-deriving that same dependency +//! information by other means. [`Expr::refs`] *is* that dependency +//! information, not extra work spent duplicating what such a crate computes +//! internally anyway. + +use super::Scene; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to +/// another parameter by name — what makes this a *named*-parameter system +/// rather than a plain calculator) and [`Expr::Call`] (a function call). +/// `Call` is in the grammar now so it doesn't need a rewrite if/when more +/// functions are wanted later (design doc §5 open question 2 leaves the +/// exact v1 function set undecided); only a small, conservative builtin set +/// is actually wired up for now (see `call_builtin`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Expr { + Number(f64), + Ref(String), + Neg(Box), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, Box), + Div(Box, Box), + Pow(Box, Box), + Call(String, Vec), +} + +impl Expr { + /// Every parameter name this expression reads, deduplicated in + /// first-seen order. This is the entire basis for cycle detection + /// (`ParameterTable::set`): one dependency edge per name returned here. + fn refs(&self, out: &mut Vec) { + match self { + Expr::Number(_) => {} + Expr::Ref(name) => { + if !out.contains(name) { + out.push(name.clone()); + } + } + Expr::Neg(a) => a.refs(out), + Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) | Expr::Div(a, b) | Expr::Pow(a, b) => { + a.refs(out); + b.refs(out); + } + Expr::Call(_, args) => { + for arg in args { + arg.refs(out); + } + } + } + } +} + +/// Everything that can go wrong parsing, defining, or resolving a +/// parameter. Every case is reported back to the caller (and, in later +/// stages, the UI) rather than panicking — matching this codebase's +/// established pattern of isolating a bad derived value instead of letting +/// it take down an unrelated computation (e.g. dangling-constraint handling +/// in `sketch_solve.rs`). +#[derive(Debug, Clone, PartialEq)] +pub enum ParamError { + /// A formula's text doesn't parse. The `String` is a short, human- + /// readable reason, not a machine-parseable code. + Parse(String), + /// Not a valid identifier, or collides with a builtin function name + /// (`is_reserved_name`). + InvalidName(String), + /// Accepting this formula would close a circular reference right now. + /// The path reads start-to-finish, e.g. `["a", "b", "a"]` for `a = b`, + /// `b = a`. + Cycle(Vec), + UnknownFunction(String), + WrongArgCount { function: String, expected: usize, found: usize }, + /// Raised by `resolve`/`resolve_all`, not `set` — a formula may + /// legitimately reference a parameter that doesn't exist *yet* (order of + /// definition shouldn't matter, mirroring a spreadsheet); this only + /// surfaces once something actually tries to read the missing value. + UndefinedReference(String), + DivisionByZero, +} + +impl fmt::Display for ParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParamError::Parse(msg) => write!(f, "formula error: {msg}"), + ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"), + ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")), + ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"), + ParamError::WrongArgCount { function, expected, found } => { + write!(f, "{function}() expects {expected} argument(s), found {found}") + } + ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"), + ParamError::DivisionByZero => write!(f, "division by zero"), + } + } +} + +impl std::error::Error for ParamError {} + +/// The v1 builtin function set — deliberately small (design doc §5 open +/// question 2 defers the full surface). Also doubles as the reserved-word +/// list `is_reserved_name` checks a parameter name against, so a formula +/// like `sqrt(x)` is never ambiguous between "call the builtin" and +/// "multiply by a parameter named sqrt". +const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"]; + +pub fn is_reserved_name(name: &str) -> bool { + BUILTIN_FUNCTIONS.contains(&name) +} + +/// A valid parameter name: starts with a letter or underscore, followed by +/// letters/digits/underscores. (Design doc §5 open question 5.) +pub fn is_valid_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_alphanumeric() || c == '_') +} + +/// A dimensional constraint's driving value (design doc §2's own suggested +/// shape for `SketchConstraint::driving_param`, stage 3 of the named- +/// parameters staged plan): either a literal number typed directly, or a +/// reference to a named parameter resolved through a [`ParameterTable`] at +/// solve time. Lives here rather than in `sketch_constraints.rs` since +/// resolving `Named` needs `ParameterTable`, which this module owns. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DrivingValue { + Literal(f64), + Named(String), +} + +impl DrivingValue { + /// Resolves to a number: a literal resolves to itself (never fails); a + /// named reference resolves through `table`, which can fail the same + /// ways any [`ParameterTable::resolve`] call can (undefined reference, + /// division by zero — a cycle should be unreachable here since `set` + /// already refuses to create one). + pub fn resolve(&self, table: &ParameterTable) -> Result { + match self { + DrivingValue::Literal(value) => Ok(*value), + DrivingValue::Named(name) => table.resolve(name), + } + } +} + +impl From for DrivingValue { + fn from(value: f64) -> Self { + DrivingValue::Literal(value) + } +} + +fn call_builtin(name: &str, args: &[f64]) -> Result { + fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> { + if args.len() != expected { + return Err(ParamError::WrongArgCount { function: name.to_string(), expected, found: args.len() }); + } + Ok(()) + } + match name { + "sqrt" => { + check_arity(name, args, 1)?; + Ok(args[0].sqrt()) + } + "abs" => { + check_arity(name, args, 1)?; + Ok(args[0].abs()) + } + "pow" => { + check_arity(name, args, 2)?; + Ok(args[0].powf(args[1])) + } + "min" => { + check_arity(name, args, 2)?; + Ok(args[0].min(args[1])) + } + "max" => { + check_arity(name, args, 2)?; + Ok(args[0].max(args[1])) + } + _ => Err(ParamError::UnknownFunction(name.to_string())), + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Number(f64), + Ident(String), + Plus, + Minus, + Star, + Slash, + Caret, + LParen, + RParen, + Comma, +} + +fn tokenize(src: &str) -> Result, ParamError> { + let chars: Vec = src.chars().collect(); + let mut tokens = Vec::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + continue; + } + match c { + '+' => { + tokens.push(Token::Plus); + i += 1; + } + '-' => { + tokens.push(Token::Minus); + i += 1; + } + '*' => { + tokens.push(Token::Star); + i += 1; + } + '/' => { + tokens.push(Token::Slash); + i += 1; + } + '^' => { + tokens.push(Token::Caret); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + ',' => { + tokens.push(Token::Comma); + i += 1; + } + _ if c.is_ascii_digit() || c == '.' => { + let start = i; + let mut seen_dot = c == '.'; + i += 1; + while i < chars.len() { + let d = chars[i]; + if d.is_ascii_digit() { + i += 1; + } else if d == '.' && !seen_dot { + seen_dot = true; + i += 1; + } else { + break; + } + } + // Scientific notation: 1e-3, 2.5E10. Only consumed when a + // digit (optionally signed) actually follows the e/E, so a + // bare trailing "e" is left for the identifier lexer instead + // (not reachable today since no builtin/constant is named + // "e", but keeps this branch from eating an identifier that + // happens to start with it, e.g. a formula like `2 * ecc`). + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + i = j; + } + } + let text: String = chars[start..i].iter().collect(); + let value: f64 = text.parse().map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?; + tokens.push(Token::Number(value)); + } + _ if c.is_alphabetic() || c == '_' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + tokens.push(Token::Ident(chars[start..i].iter().collect())); + } + other => return Err(ParamError::Parse(format!("unexpected character '{other}'"))), + } + } + Ok(tokens) +} + +struct Parser<'a> { + tokens: &'a [Token], + pos: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn bump(&mut self) -> Option { + let t = self.tokens.get(self.pos).cloned(); + if t.is_some() { + self.pos += 1; + } + t + } + + fn expect(&mut self, expected: Token) -> Result<(), ParamError> { + match self.bump() { + Some(t) if t == expected => Ok(()), + Some(t) => Err(ParamError::Parse(format!("expected {expected:?}, found {t:?}"))), + None => Err(ParamError::Parse(format!("expected {expected:?}, found end of formula"))), + } + } + + // Precedence, low to high: expr (+ -) > term (* /) > unary (-) > power (^) > primary. + // `^` binds tighter than unary minus on its left operand but is + // right-associative on its own exponent (`parse_unary` recursing back + // into `parse_power` covers `2^-2` == `2^(-2)`, and `2^3^2` == + // `2^(3^2)`). + fn parse_expr(&mut self) -> Result { + let mut lhs = self.parse_term()?; + loop { + match self.peek() { + Some(Token::Plus) => { + self.pos += 1; + lhs = Expr::Add(Box::new(lhs), Box::new(self.parse_term()?)); + } + Some(Token::Minus) => { + self.pos += 1; + lhs = Expr::Sub(Box::new(lhs), Box::new(self.parse_term()?)); + } + _ => break, + } + } + Ok(lhs) + } + + fn parse_term(&mut self) -> Result { + let mut lhs = self.parse_unary()?; + loop { + match self.peek() { + Some(Token::Star) => { + self.pos += 1; + lhs = Expr::Mul(Box::new(lhs), Box::new(self.parse_unary()?)); + } + Some(Token::Slash) => { + self.pos += 1; + lhs = Expr::Div(Box::new(lhs), Box::new(self.parse_unary()?)); + } + _ => break, + } + } + Ok(lhs) + } + + fn parse_unary(&mut self) -> Result { + match self.peek() { + Some(Token::Minus) => { + self.pos += 1; + Ok(Expr::Neg(Box::new(self.parse_unary()?))) + } + Some(Token::Plus) => { + self.pos += 1; + self.parse_unary() + } + _ => self.parse_power(), + } + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_primary()?; + if let Some(Token::Caret) = self.peek() { + self.pos += 1; + let exponent = self.parse_unary()?; + return Ok(Expr::Pow(Box::new(base), Box::new(exponent))); + } + Ok(base) + } + + fn parse_primary(&mut self) -> Result { + match self.bump() { + Some(Token::Number(n)) => Ok(Expr::Number(n)), + Some(Token::Ident(name)) => { + if matches!(self.peek(), Some(Token::LParen)) { + self.pos += 1; + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Token::RParen)) { + loop { + args.push(self.parse_expr()?); + if matches!(self.peek(), Some(Token::Comma)) { + self.pos += 1; + } else { + break; + } + } + } + self.expect(Token::RParen)?; + Ok(Expr::Call(name, args)) + } else { + Ok(Expr::Ref(name)) + } + } + Some(Token::LParen) => { + let inner = self.parse_expr()?; + self.expect(Token::RParen)?; + Ok(inner) + } + Some(other) => Err(ParamError::Parse(format!("unexpected token {other:?}"))), + None => Err(ParamError::Parse("unexpected end of formula".to_string())), + } + } +} + +/// Parses a formula's text into an [`Expr`], without evaluating or +/// resolving any reference. `ParameterTable::set` is the usual entry point; +/// exposed standalone so a UI stage can validate-as-you-type without +/// touching the table. +pub fn parse(source: &str) -> Result { + let tokens = tokenize(source)?; + if tokens.is_empty() { + return Err(ParamError::Parse("empty formula".to_string())); + } + let mut parser = Parser { tokens: &tokens, pos: 0 }; + let expr = parser.parse_expr()?; + if parser.pos != tokens.len() { + return Err(ParamError::Parse(format!("unexpected trailing token {:?}", tokens[parser.pos]))); + } + Ok(expr) +} + +/// One named parameter: a name plus its formula, both the original typed +/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`] +/// (`expr`, for evaluation). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Parameter { + pub name: String, + pub source: String, + pub expr: Expr, +} + +/// Every named parameter for one document (design doc §2: per-document, not +/// per-[`super::sketch_constraints::SketchScope`] — a block/xref does not +/// get its own independent table; open question 4 leaves whether that's the +/// final answer, but it's this stage's working assumption). +/// +/// `parameters` is intentionally not `pub` (unlike the sibling +/// `SketchConstraintSet::constraints`): this table's entire value is that +/// every stored formula is already known to be parse-valid and cycle-free, +/// an invariant only `set`/`remove` maintain. A direct external push could +/// silently violate it. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ParameterTable { + parameters: Vec, +} + +impl ParameterTable { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.parameters.is_empty() + } + + pub fn len(&self) -> usize { + self.parameters.len() + } + + /// Insertion order — the order parameters were first defined in, stable + /// across edits to existing ones. The natural listing order for a + /// future parameters panel. + pub fn iter(&self) -> impl Iterator { + self.parameters.iter() + } + + pub fn get(&self, name: &str) -> Option<&Parameter> { + self.parameters.iter().find(|p| p.name == name) + } + + pub fn contains(&self, name: &str) -> bool { + self.get(name).is_some() + } + + fn index_of(&self, name: &str) -> Option { + self.parameters.iter().position(|p| p.name == name) + } + + /// Defines a new parameter or redefines an existing one's formula (one + /// upsert operation — there is no separate "must not already exist" + /// entry point; a future UI stage that wants add-vs-rename semantics can + /// check `contains` itself first). + /// + /// Rejects: an invalid identifier or one colliding with a builtin + /// function name (`is_valid_name`/`is_reserved_name`), a formula that + /// fails to parse, and — the design doc's explicit requirement — a + /// formula that would close a circular reference right now. A reference + /// to a parameter that doesn't exist *yet* is not rejected (order of + /// definition shouldn't matter, mirroring a spreadsheet); it only + /// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`] + /// actually needs that value. + pub fn set(&mut self, name: &str, source: &str) -> Result<(), ParamError> { + if !is_valid_name(name) || is_reserved_name(name) { + return Err(ParamError::InvalidName(name.to_string())); + } + let expr = parse(source)?; + self.check_no_cycle(name, &expr)?; + match self.index_of(name) { + Some(i) => { + self.parameters[i].source = source.to_string(); + self.parameters[i].expr = expr; + } + None => self.parameters.push(Parameter { name: name.to_string(), source: source.to_string(), expr }), + } + Ok(()) + } + + /// Removes a parameter by name. Returns whether one was actually + /// removed. Deliberately does not check whether another parameter's + /// formula still references `name` — that becomes an + /// [`ParamError::UndefinedReference`] for the referencing parameter the + /// next time it's resolved, the same graceful-degradation treatment an + /// unresolvable reference gets everywhere else in this module, rather + /// than a special "can't delete, still in use" rule this stage doesn't + /// need to invent (a future UI stage may still want to warn before + /// deleting — that's a UI-layer decision, not a data-layer one). + pub fn remove(&mut self, name: &str) -> bool { + match self.index_of(name) { + Some(i) => { + self.parameters.remove(i); + true + } + None => false, + } + } + + /// Would accepting `new_expr` as `name`'s formula close a circular + /// reference right now? Walks the dependency graph from each of + /// `new_expr`'s direct references, following each visited parameter's + /// *existing* stored formula, looking for a path back to `name`. A + /// reference to a name not currently in the table just terminates that + /// branch — no cycle through something that doesn't exist yet. + fn check_no_cycle(&self, name: &str, new_expr: &Expr) -> Result<(), ParamError> { + let mut direct_refs = Vec::new(); + new_expr.refs(&mut direct_refs); + for start in &direct_refs { + let mut visiting = Vec::new(); + if let Some(mut path) = self.find_path(start, name, &mut visiting) { + let mut full = vec![name.to_string()]; + full.append(&mut path); + return Err(ParamError::Cycle(full)); + } + } + Ok(()) + } + + /// Depth-first search from `current` to `target`, following each + /// visited parameter's existing formula's references. Returns the path + /// (starting with `current`) if `target` is reachable. + fn find_path(&self, current: &str, target: &str, visiting: &mut Vec) -> Option> { + if current == target { + return Some(vec![current.to_string()]); + } + if visiting.iter().any(|v| v == current) { + // A cycle that doesn't involve `target` shouldn't be reachable + // (`set` refuses to create one), but bail out rather than loop + // forever if that invariant is ever violated some other way + // (e.g. a foreign/hand-edited file deserialized straight into a + // table without going through `set`). + return None; + } + let param = self.get(current)?; + visiting.push(current.to_string()); + let mut refs = Vec::new(); + param.expr.refs(&mut refs); + for r in &refs { + if let Some(mut rest) = self.find_path(r, target, visiting) { + let mut path = vec![current.to_string()]; + path.append(&mut rest); + visiting.pop(); + return Some(path); + } + } + visiting.pop(); + None + } + + /// Resolves one parameter's numeric value, evaluating its dependency + /// chain as needed. No state is kept between calls (a fresh memo cache + /// every call) — mirrors `refresh_sketch_constraints`'s + /// rebuild-from-scratch-per-trigger model (design doc §2): this stage + /// has no document/UI integration yet to decide when a cached value + /// would go stale, so it simply never caches across calls. + pub fn resolve(&self, name: &str) -> Result { + let mut cache = HashMap::new(); + let mut stack = Vec::new(); + self.resolve_inner(name, &mut cache, &mut stack) + } + + /// Resolves every parameter, isolating a failure to just the parameters + /// it actually affects rather than aborting the whole table — the same + /// don't-let-one-bad-item-take-down-everything-else treatment this + /// codebase already gives dangling constraints and similar derived-data + /// failures elsewhere. + pub fn resolve_all(&self) -> HashMap> { + let mut out = HashMap::with_capacity(self.parameters.len()); + for param in &self.parameters { + out.insert(param.name.clone(), self.resolve(¶m.name)); + } + out + } + + fn resolve_inner(&self, name: &str, cache: &mut HashMap, stack: &mut Vec) -> Result { + if let Some(v) = cache.get(name) { + return Ok(*v); + } + if stack.iter().any(|s| s == name) { + // Defense in depth only — `set` already refuses to create a + // cycle; see `find_path`'s doc comment for how one could still + // end up here. + let mut cyc = stack.clone(); + cyc.push(name.to_string()); + return Err(ParamError::Cycle(cyc)); + } + let param = self.get(name).ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?; + stack.push(name.to_string()); + let value = self.eval(¶m.expr, cache, stack)?; + stack.pop(); + cache.insert(name.to_string(), value); + Ok(value) + } + + fn eval(&self, expr: &Expr, cache: &mut HashMap, stack: &mut Vec) -> Result { + Ok(match expr { + Expr::Number(n) => *n, + Expr::Ref(name) => self.resolve_inner(name, cache, stack)?, + Expr::Neg(a) => -self.eval(a, cache, stack)?, + Expr::Add(a, b) => self.eval(a, cache, stack)? + self.eval(b, cache, stack)?, + Expr::Sub(a, b) => self.eval(a, cache, stack)? - self.eval(b, cache, stack)?, + Expr::Mul(a, b) => self.eval(a, cache, stack)? * self.eval(b, cache, stack)?, + Expr::Div(a, b) => { + let denom = self.eval(b, cache, stack)?; + if denom == 0.0 { + return Err(ParamError::DivisionByZero); + } + self.eval(a, cache, stack)? / denom + } + Expr::Pow(a, b) => self.eval(a, cache, stack)?.powf(self.eval(b, cache, stack)?), + Expr::Call(fname, args) => { + let mut values = Vec::with_capacity(args.len()); + for arg in args { + values.push(self.eval(arg, cache, stack)?); + } + call_builtin(fname, &values)? + } + }) + } +} + +impl Scene { + /// The document's named-parameter table (design doc §2: one per + /// document). Read-only accessor; use [`Scene::named_parameters_mut`] to + /// define/redefine/remove a parameter. + pub fn named_parameters(&self) -> &ParameterTable { + &self.named_parameters + } + + pub fn named_parameters_mut(&mut self) -> &mut ParameterTable { + &mut self.named_parameters + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_arithmetic_with_ordinary_precedence() { + // 2 + 3 * 4 == 14, not 20 -- proves * binds tighter than +. + assert_eq!(eval_literal("2 + 3 * 4"), 14.0); + // (2 + 3) * 4 == 20 -- parens override precedence. + assert_eq!(eval_literal("(2 + 3) * 4"), 20.0); + // 2 ^ 3 ^ 2 == 2 ^ (3 ^ 2) == 512 -- right-associative power. + assert_eq!(eval_literal("2 ^ 3 ^ 2"), 512.0); + // Unary minus binds tighter than power's left operand is *read*, + // but -2^2 must still equal -(2^2) = -4 under this grammar (unary + // parses before power), matching common calculator convention. + assert_eq!(eval_literal("-2^2"), -4.0); + assert_eq!(eval_literal("2^-2"), 0.25); + assert_eq!(eval_literal("10 / 4"), 2.5); + assert_eq!(eval_literal("-(3 + 4)"), -7.0); + } + + fn eval_literal(src: &str) -> f64 { + let table = ParameterTable::new(); + let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}")); + table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_or_else(|e| panic!("eval '{src}': {e}")) + } + + #[test] + fn parses_scientific_notation() { + assert_eq!(eval_literal("1e3"), 1000.0); + assert_eq!(eval_literal("1.5e-2"), 0.015); + } + + #[test] + fn rejects_malformed_formulas() { + assert!(matches!(parse(""), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 +"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("(1 + 2"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 2"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 @ 2"), Err(ParamError::Parse(_)))); + } + + #[test] + fn builtin_functions_evaluate_and_check_arity() { + assert_eq!(eval_literal("sqrt(16)"), 4.0); + assert_eq!(eval_literal("abs(-5)"), 5.0); + assert_eq!(eval_literal("min(3, 7)"), 3.0); + assert_eq!(eval_literal("max(3, 7)"), 7.0); + assert_eq!(eval_literal("pow(2, 10)"), 1024.0); + + let table = ParameterTable::new(); + let expr = parse("sqrt(1, 2)").unwrap(); + let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); + assert_eq!(err, ParamError::WrongArgCount { function: "sqrt".to_string(), expected: 1, found: 2 }); + + let expr = parse("bogus(1)").unwrap(); + let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); + assert_eq!(err, ParamError::UnknownFunction("bogus".to_string())); + } + + #[test] + fn division_by_zero_is_reported_not_panicked() { + let table = ParameterTable::new(); + let expr = parse("1 / 0").unwrap(); + assert_eq!(table.eval(&expr, &mut HashMap::new(), &mut Vec::new()), Err(ParamError::DivisionByZero)); + } + + #[test] + fn set_rejects_invalid_and_reserved_names() { + let mut table = ParameterTable::new(); + assert!(matches!(table.set("2bad", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!(table.set("has space", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!(table.set("", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!(table.set("sqrt", "1"), Err(ParamError::InvalidName(_)))); + assert!(table.set("_valid1", "1").is_ok()); + } + + #[test] + fn a_literal_parameter_resolves_to_its_number() { + let mut table = ParameterTable::new(); + table.set("plate_len", "42.5").unwrap(); + assert_eq!(table.resolve("plate_len"), Ok(42.5)); + } + + #[test] + fn a_formula_resolves_through_a_reference_chain() { + let mut table = ParameterTable::new(); + table.set("hole_dia", "5").unwrap(); + table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + table.set("row_span", "3 * hole_spacing").unwrap(); + assert_eq!(table.resolve("hole_spacing"), Ok(11.5)); + assert_eq!(table.resolve("row_span"), Ok(34.5)); + } + + #[test] + fn resolving_an_undefined_reference_reports_which_name_is_missing() { + let mut table = ParameterTable::new(); + table.set("a", "b + 1").unwrap(); + assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + } + + #[test] + fn set_rejects_a_direct_self_reference() { + let mut table = ParameterTable::new(); + let err = table.set("a", "a + 1").unwrap_err(); + assert!(matches!(err, ParamError::Cycle(_))); + assert!(!table.contains("a"), "a rejected formula must not be stored"); + } + + #[test] + fn set_rejects_an_indirect_cycle_at_the_definition_that_closes_it() { + let mut table = ParameterTable::new(); + table.set("a", "b").unwrap(); + // b doesn't exist yet -- not a cycle, a is just unresolved for now. + assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + // Defining b = a closes the loop right here and must be rejected, + // leaving b undefined rather than silently accepted. + let err = table.set("b", "a").unwrap_err(); + assert!(matches!(err, ParamError::Cycle(_))); + assert!(!table.contains("b")); + // a's formula (still "b") is untouched -- it stays unresolved, not + // corrupted by the rejected attempt. + assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + } + + #[test] + fn set_allows_redefining_an_existing_parameter_to_break_a_would_be_cycle() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("b", "a + 1").unwrap(); + // Redefining a in terms of b must be rejected (a <- b <- a)... + assert!(table.set("a", "b + 1").is_err()); + // ...but redefining a to something unrelated is always fine. + table.set("a", "5").unwrap(); + assert_eq!(table.resolve("a"), Ok(5.0)); + assert_eq!(table.resolve("b"), Ok(6.0)); + } + + #[test] + fn resolve_all_isolates_a_failure_to_the_affected_parameters_only() { + let mut table = ParameterTable::new(); + table.set("good", "10").unwrap(); + table.set("bad", "missing + 1").unwrap(); + table.set("depends_on_bad", "bad * 2").unwrap(); + + let results = table.resolve_all(); + assert_eq!(results.get("good"), Some(&Ok(10.0))); + assert_eq!(results.get("bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); + assert_eq!(results.get("depends_on_bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); + } + + #[test] + fn remove_drops_a_parameter_and_leaves_dependents_undefined_not_panicking() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("b", "a + 1").unwrap(); + assert!(table.remove("a")); + assert!(!table.remove("a"), "removing an already-removed name reports false"); + assert_eq!(table.resolve("b"), Err(ParamError::UndefinedReference("a".to_string()))); + } + + #[test] + fn set_upserts_an_existing_parameters_formula() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("a", "2").unwrap(); + assert_eq!(table.len(), 1, "redefining must not create a second entry"); + assert_eq!(table.resolve("a"), Ok(2.0)); + } + + #[test] + fn table_round_trips_through_bincode() { + let mut table = ParameterTable::new(); + table.set("hole_dia", "5").unwrap(); + table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + + let bytes = bincode::serialize(&table).expect("serialize"); + let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize"); + + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("hole_spacing"), Ok(11.5)); + assert_eq!(restored.get("hole_dia").unwrap().source, "5"); + } + + #[test] + fn iter_preserves_insertion_order_across_redefinitions() { + let mut table = ParameterTable::new(); + table.set("z", "1").unwrap(); + table.set("a", "2").unwrap(); + table.set("z", "3").unwrap(); // redefine, should not move to the end + let names: Vec<&str> = table.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["z", "a"]); + } +} diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs new file mode 100644 index 000000000..ff09b7085 --- /dev/null +++ b/src/scene/named_parameters_persist.rs @@ -0,0 +1,201 @@ +//! Save/load integration for [`super::named_parameters::ParameterTable`] — +//! design doc §4 stage 2 (the XRecord persistence spike) plus enough of +//! stage 2's "wire it into open/save" to actually be useful, following +//! [`super::sketch_persist`]'s exact precedent (itself the sketch-constraint +//! project's stage 4/§5.2): the "lazy" model, where the table lives only in +//! `Scene::named_parameters` in-memory during editing and is materialized +//! into `document.objects` as one XRecord right before a save, then read +//! back right after a load. +//! +//! The one structural difference from `sketch_persist`: a +//! [`super::named_parameters::ParameterTable`] is document-wide, not +//! per-[`super::sketch_constraints::SketchScope`], so there is exactly one +//! XRecord for the whole document rather than one per scope owner. +//! +//! **Owner handle, and a real round-trip gap found while building this**: +//! the obvious choice was `document.header.named_objects_dict_handle` (the +//! document's own root Named Objects Dictionary) — `extension_dictionary_ +//! handle` in the vendored `cadcodec` source explicitly resolves an +//! extension dictionary for a `Dictionary`-type owner (`ObjectType:: +//! Dictionary(value) => value.xdictionary_handle`), and that crate's own +//! tests (e.g. `tests/issue51.rs`) use `named_objects_dict_handle` as an +//! XRecord owner directly. In practice this round-tripped through DWG but +//! **silently lost the XRecord through DXF** (caught by this module's own +//! save/load tests, not assumed): `CadDocument::ensure_xrecord` only writes +//! the owner's *own* `xdictionary_handle` field when `get_entity_mut(owner)` +//! succeeds (a real "entity"); a `Dictionary` owner like the root NOD isn't +//! one, so `ensure_xrecord` falls back to a runtime-only side map +//! (`xdic_by_handle`) instead. The DWG writer consults that side map +//! (`io/dwg/dwg_document_builder.rs` et al.); the DXF writer never does (no +//! `xdic_by_handle` reference anywhere under `io/dxf/writer/`) — so a +//! Dictionary-owned extension dictionary is invisible to a DXF save. This +//! is a gap in the vendored crate for this specific owner-type combination, +//! not something introduced here; per this project's own stated preference +//! to avoid needing changes to `acadrust`/`cadkernel`, the fix is to pick a +//! different, already-proven owner rather than patch it. +//! +//! **Owner used instead**: `document.header.model_space_block_handle` — a +//! real `BlockRecord` entity, the exact anchor `sketch_persist`'s own +//! `SketchScope::ModelSpace` case already proved round-trips cleanly +//! through both DWG and DXF. Model space is not a semantically perfect fit +//! for "the whole document" (a parameter table has nothing to do with model +//! space specifically), but it is guaranteed to exist in every +//! `CadDocument` and is a real entity, which is the property that actually +//! matters here. + +use super::named_parameters::ParameterTable; +use super::Scene; + +const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; + +/// Prefixed onto the serialized blob so a future schema change can be +/// detected and gracefully skipped rather than silently misreading bytes — +/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the +/// migration path itself is still undesigned. +const FORMAT_VERSION: u8 = 1; + +fn encode(table: &ParameterTable) -> Option> { + let mut bytes = vec![FORMAT_VERSION]; + bytes.extend(bincode::serialize(table).ok()?); + Some(bytes) +} + +fn decode(bytes: &[u8]) -> Option { + let (&version, body) = bytes.split_first()?; + if version != FORMAT_VERSION { + return None; + } + bincode::deserialize(body).ok() +} + +impl Scene { + /// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS` + /// XRecord, ready for whatever save call happens next to serialize + /// `self.document` as-is. Called from the same save entry points as + /// `materialize_sketch_constraints_for_save` + /// (`OpenCADStudio::prepare_native_save`, the wasm save path). + /// + /// An empty table is skipped rather than writing an empty XRecord — an + /// unconstrained-by-parameters drawing (the common case, at least until + /// this feature is used) should not gain persisted-but-empty + /// bookkeeping on every save. + pub(crate) fn materialize_named_parameters_for_save(&mut self) { + if self.named_parameters.is_empty() { + return; + } + let Some(bytes) = encode(&self.named_parameters) else { return }; + let owner = self.document.header.model_space_block_handle; + if owner.is_null() { + return; + } + self.document.ensure_xrecord(owner, XRECORD_KEY); + if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { + // Overwrite, not append: a resave must replace the prior blob, + // not accumulate one more Chunk entry every time. + record.entries.clear(); + record + .entries + .push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(bytes))); + } + } + + /// Populates `named_parameters` from the just-loaded `self.document`'s + /// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a + /// document open installs its `CadDocument` into this `Scene`, the same + /// call sites `load_sketch_constraints_from_document` already has + /// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"` + /// ops). Replaces whatever was already in `named_parameters` (a fresh + /// table for a real open; `"new"`'s blank document has nothing to find + /// anyway). + pub(crate) fn load_named_parameters_from_document(&mut self) { + self.named_parameters = ParameterTable::new(); + let owner = self.document.header.model_space_block_handle; + if owner.is_null() { + return; + } + let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return }; + let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value { + acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()), + _ => None, + }) else { + return; + }; + if let Some(table) = decode(&bytes) { + self.named_parameters = table; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_parameter_table_survives_materialize_and_reload() { + let mut scene = Scene::new(); + scene.named_parameters_mut().set("hole_dia", "5").unwrap(); + scene.named_parameters_mut().set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + + scene.materialize_named_parameters_for_save(); + // Simulate the load-time side of a save/reload round trip: drop the + // in-memory table (a fresh open starts empty) and repopulate purely + // from what materialize just wrote into `document.objects`. + scene.named_parameters = ParameterTable::new(); + scene.load_named_parameters_from_document(); + + assert_eq!(scene.named_parameters().len(), 2); + assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5)); + assert_eq!(scene.named_parameters().get("hole_dia").unwrap().source, "5"); + } + + /// The above test only proves `materialize`/`load` agree with each + /// other on the same in-memory `CadDocument` — this drives a real + /// `save_to_bytes`/`load_bytes` round trip (the same primitives + /// `OpenCADStudio::on_file_opened`'s native save/open path uses), + /// through both supported formats, mirroring + /// `sketch_persist`'s own real-bytes tests. + fn full_bytes_roundtrip(ext: &str) -> ParameterTable { + let mut scene = Scene::new(); + scene.named_parameters_mut().set("plate_len", "42.5").unwrap(); + scene.named_parameters_mut().set("plate_width", "plate_len / 2").unwrap(); + + scene.materialize_named_parameters_for_save(); + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); + reloaded_scene.load_named_parameters_from_document(); + + reloaded_scene.named_parameters + } + + #[test] + fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() { + let restored = full_bytes_roundtrip("dxf"); + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("plate_width"), Ok(21.25)); + } + + #[test] + fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() { + let restored = full_bytes_roundtrip("dwg"); + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("plate_width"), Ok(21.25)); + } + + #[test] + fn an_empty_table_is_not_materialized() { + let mut scene = Scene::new(); + scene.materialize_named_parameters_for_save(); + let owner = scene.document.header.model_space_block_handle; + assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty table should not create an XRecord"); + } + + #[test] + fn decode_rejects_a_mismatched_format_version() { + let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; + bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap()); + assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread"); + } +} diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index a5bd58246..a3ece49ce 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -11,6 +11,7 @@ //! entities and is meant to be re-solved every time referenced geometry //! changes, not applied once. +use super::named_parameters::DrivingValue; use acadrust::types::{Handle, Vector3}; use serde::{Deserialize, Serialize}; @@ -90,11 +91,14 @@ pub struct SketchConstraint { pub id: ConstraintId, pub kind: ConstraintKind, pub refs: Vec, - /// The typed target for a dimensional constraint (a `Distance`'s - /// length, an `Angle`'s degrees, a `Radius`'s radius). `None` for every - /// purely-geometric kind (Coincident, Horizontal, Vertical, Parallel, - /// Perpendicular, Equal, Tangent). - pub driving_param: Option, + /// The target for a dimensional constraint (a `Distance`'s length, an + /// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a + /// named-parameter reference (`docs/named_parameters_design.md`, + /// resolved through `Scene::named_parameters` at solve time by + /// `sketch_solve::build_constraint`). `None` for every purely-geometric + /// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, + /// Equal, Tangent). + pub driving_param: Option, /// Lets a user suppress a constraint without losing it — a re-solve /// skips a disabled constraint entirely. pub enabled: bool, @@ -160,7 +164,7 @@ impl SketchConstraintSet { } /// Appends a constraint, assigning it a fresh id unique within this set. - pub fn add(&mut self, kind: ConstraintKind, refs: Vec, driving_param: Option) -> ConstraintId { + pub fn add(&mut self, kind: ConstraintKind, refs: Vec, driving_param: Option) -> ConstraintId { let id = self.next_id; self.next_id += 1; self.constraints.push(SketchConstraint { id, kind, refs, driving_param, enabled: true }); @@ -348,9 +352,17 @@ impl ConstraintKind { /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { - match (constraint.kind, constraint.driving_param) { - (ConstraintKind::Angle, Some(value)) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()), - (_, Some(value)) => format!("{} {value:.2}", constraint.kind.glyph_symbol()), + match (constraint.kind, &constraint.driving_param) { + (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()), + (_, Some(DrivingValue::Literal(value))) => format!("{} {value:.2}", constraint.kind.glyph_symbol()), + // A named reference has no single resolved number to show without + // threading `ParameterTable` into every glyph-render call site + // (`src/ui/overlay.rs`) — showing the name itself is enough for now; + // stage 4's parameters panel is the natural place to reconsider this + // once a named `driving_param` can actually be authored through the + // UI (nothing can yet — this arm exists so the match is exhaustive + // and correct ahead of that UI, not because it's reachable today). + (_, Some(DrivingValue::Named(name))) => format!("{} {name}", constraint.kind.glyph_symbol()), (_, None) => constraint.kind.glyph_symbol().to_string(), } } @@ -415,7 +427,7 @@ impl super::Scene { if handle_map.is_empty() { return; } - let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = Vec::new(); + let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = Vec::new(); for (scope_index, set) in self.sketch_constraints.iter().enumerate() { for c in &set.constraints { if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { @@ -423,7 +435,7 @@ impl super::Scene { } let new_refs: Vec = c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect(); - to_add.push((scope_index, c.kind, new_refs, c.driving_param)); + to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); } } if to_add.is_empty() { @@ -453,10 +465,10 @@ mod tests { fn add_assigns_increasing_ids_and_get_finds_them() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); - let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(25.0)); + let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(DrivingValue::Literal(25.0))); assert_ne!(a, b); assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); - assert_eq!(set.get(b).unwrap().driving_param, Some(25.0)); + assert_eq!(set.get(b).unwrap().driving_param, Some(DrivingValue::Literal(25.0))); } #[test] @@ -521,14 +533,14 @@ mod tests { fn sketch_constraint_set_round_trips_through_bincode() { let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None); - set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(12.5)); + set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(DrivingValue::Literal(12.5))); let bytes = bincode::serialize(&set).expect("serialize"); let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize"); assert_eq!(restored.scope, set.scope); assert_eq!(restored.constraints.len(), set.constraints.len()); - assert_eq!(restored.constraints[1].driving_param, Some(12.5)); + assert_eq!(restored.constraints[1].driving_param, Some(DrivingValue::Literal(12.5))); assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); } } diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs index 4cfe0f7bd..b0f224540 100644 --- a/src/scene/sketch_persist.rs +++ b/src/scene/sketch_persist.rs @@ -48,7 +48,15 @@ fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) { /// or panicking — design doc §5.2's explicit call-out, and §7's flagged /// "no migration path designed yet": bumping this is the signal a real /// migration is needed, not a substitute for one. -const FORMAT_VERSION: u8 = 1; +/// +/// Bumped 1 -> 2 by `named_parameters_design.md` stage 3: +/// `SketchConstraint::driving_param` changed from a bare `Option` to +/// `Option` (an enum, with its own +/// discriminant), so a version-1 blob's bytes no longer line up with the +/// current struct shape — exactly the "detected and gracefully skipped" +/// case this version byte exists for, not something to migrate (no +/// migration path exists yet, per the call-out above). +const FORMAT_VERSION: u8 = 2; fn encode(set: &SketchConstraintSet) -> Option> { let mut bytes = vec![FORMAT_VERSION]; @@ -192,7 +200,11 @@ mod tests { vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], None, ); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Distance, vec![SketchRef::whole(b)], Some(12.5)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::whole(b)], + Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), + ); scene.materialize_sketch_constraints_for_save(); let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -213,7 +225,7 @@ mod tests { assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!(restored.constraints[1].driving_param, Some(12.5)); + assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))); } #[test] @@ -222,7 +234,7 @@ mod tests { assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!(restored.constraints[1].driving_param, Some(12.5)); + assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))); } #[test] diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index c807d2443..cd9d3a755 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -38,6 +38,7 @@ use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint}; use ocs_gcs::solvers::dogleg::solve_dl; use ocs_gcs::system::System; +use super::named_parameters::ParameterTable; use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; use super::{ChangeKind, Scene}; @@ -113,14 +114,21 @@ fn resolve_ref( /// One [`SketchConstraint`]'s `ocs_gcs` construction, or `None` if it can't /// be built (an unsupported/not-yet-mapped kind, a dangling ref, a ref /// shape the kind doesn't expect — e.g. `Parallel` needs two whole-line -/// refs). A constraint that can't be built is simply skipped for this -/// solve, not an error: geometry it would have constrained is left alone, -/// matching how a dangling associative-dimension reference degrades today -/// rather than aborting the whole recompute. +/// refs — or, for a dimensional kind, a `driving_param` that fails to +/// resolve: an undefined named-parameter reference or a division by zero in +/// its formula, design doc `named_parameters_design.md` stage 3). A +/// constraint that can't be built is simply skipped for this solve, not an +/// error: geometry it would have constrained is left alone, matching how a +/// dangling associative-dimension reference degrades today rather than +/// aborting the whole recompute. (A named-parameter *cycle* should be +/// unreachable here — `ParameterTable::set` already refuses to create one — +/// so it gets the same treatment as any other resolve failure rather than a +/// special case.) fn build_constraint( document: &acadrust::CadDocument, sys: &mut System, cache: &mut HashMap, + params: &ParameterTable, c: &SketchConstraint, ) -> Option> { if !c.enabled { @@ -181,18 +189,18 @@ fn build_constraint( ConstraintKind::Distance => { let [a, b] = c.refs.as_slice() else { return None }; let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?); - let target = sys.add_param(c.driving_param?, true); + let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true); Some(Rc::new(P2PDistance::new(pa, pb, target))) } ConstraintKind::Angle => { let [a, b] = c.refs.as_slice() else { return None }; let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?); - let angle = sys.add_param(c.driving_param?.to_radians(), true); + let angle = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?.to_radians(), true); Some(Rc::new(L2LAngle::new(fixed, moving, angle))) } ConstraintKind::Radius => { let circle = whole_circle(sys, cache, *c.refs.first()?)?; - let target = sys.add_param(c.driving_param?, true); + let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true); Some(Rc::new(Equal::new(circle.rad, target, 1.0))) } ConstraintKind::Tangent => { @@ -252,6 +260,7 @@ const MOVE_EPS: f64 = 1e-9; /// on a successful solve. fn solve_scope( document: &acadrust::CadDocument, + params: &ParameterTable, set: &SketchConstraintSet, ) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> { let mut sys = System::new(); @@ -266,7 +275,7 @@ fn solve_scope( let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); for c in &set.constraints { - let Some(constraint) = build_constraint(document, &mut sys, &mut cache, c) else { continue }; + let Some(constraint) = build_constraint(document, &mut sys, &mut cache, params, c) else { continue }; owner.push((constraint.clone(), c.id)); sys.add_constraint(constraint); // Coincident needs both X and Y equal; `build_constraint` returns @@ -412,7 +421,9 @@ impl Scene { if !touched { continue; } - let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.sketch_constraints[i]) else { continue }; + let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.named_parameters, &self.sketch_constraints[i]) else { + continue; + }; self.sketch_constraints[i].dof = Some(dof); self.sketch_constraints[i].conflicts = conflicts; for (handle, new_entity) in solved { @@ -470,7 +481,7 @@ impl Scene { if !is_touched { continue; } - let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, set) else { continue }; + let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, &self.named_parameters, set) else { continue }; result.extend(solved); } result diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index c4e345c8d..c43b80b7c 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -18,3 +18,4 @@ pub mod options; pub mod attribute_editor; pub mod alias_editor; pub mod find_replace; +pub mod named_parameters; diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs new file mode 100644 index 000000000..cbcb97667 --- /dev/null +++ b/src/ui/window/named_parameters.rs @@ -0,0 +1,258 @@ +//! Named-parameter table editor — an in-canvas modal (Plan B), mirroring +//! `alias_editor.rs`'s buffered-rows pattern exactly. Opened by PARAMETERS +//! (`docs/named_parameters_design.md` stage 4). Rows are `(name, formula)`, +//! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`) +//! and committed to `Scene::named_parameters` only on Apply — matching the +//! alias editor's "closing discards unapplied edits" convention, which +//! suits a formula table even better: `ParameterTable::set` validates (and +//! can reject) a formula immediately, so typing a not-yet-finished name or +//! formula must not touch the live table on every keystroke. +//! +//! Unlike the alias editor, each row also shows a live-computed resolved +//! value or error (`preview`, below) — built fresh from the whole buffer on +//! every render, not cached: the same "cheap enough to recompute from +//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use +//! throughout this project, and it means the preview reflects circular/ +//! undefined-reference problems across rows immediately, before Apply. + +use crate::app::Message; +use crate::scene::named_parameters::ParameterTable; +use crate::t; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use iced::{Background, Element, Length, Theme}; + +/// Which column of a parameter row a text edit targets. +#[derive(Clone, Copy, Debug)] +pub enum ParamField { + Name, + Formula, +} + +/// One working-buffer row. Free text until Apply — `ParameterTable::set` +/// does the real validation then, not on every keystroke. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParamEditorRow { + pub name: String, + pub formula: String, +} + +/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ +/// column — same convention and value as `alias_editor::GUTTER`. +const GUTTER: f32 = 16.0; + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.68)) } +} + +fn danger_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { color: Some(theme.palette().danger.base.color) } +} + +/// Every buffer index sharing a (trimmed) name with at least one other +/// non-blank row — `ParameterTable::set` upserts by name, so two rows typed +/// with the same name would otherwise silently collapse into "whichever one +/// happens to `set` last wins," discarding the other's formula with no +/// indication anything was lost. Both `preview` and the real Apply +/// (`OpenCADStudio::apply_named_parameter_editor_rows`) check this first and +/// refuse every row in a name collision rather than guessing which one the +/// user meant. +pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::HashSet { + let mut by_name: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new(); + for (idx, row) in rows.iter().enumerate() { + let name = row.name.trim(); + if !name.is_empty() { + by_name.entry(name).or_default().push(idx); + } + } + by_name.into_values().filter(|idxs| idxs.len() > 1).flatten().collect() +} + +/// Builds a scratch table from every row's *current* text and resolves each +/// row's own value against it, so the preview reflects cross-row references +/// (including a row referencing one defined further down — order in the +/// buffer doesn't matter, same as it doesn't for the real table). Returns +/// one `Result` per row, in row order: +/// - `Err` from a blank name is suppressed (`Ok` with no text shown instead, +/// handled by the caller) — a fresh row from "+ Add parameter" before the +/// user has typed a name yet isn't an error to flag. +/// - A name shared with another row is its own error (`duplicate_name_rows`) +/// before anything is even attempted. +/// - `Err` from `ParameterTable::set` itself (bad name, parse error, a +/// formula that would close a cycle right now) is that row's own problem. +/// - `Err(UndefinedReference)`/other resolve failure means the row's own +/// formula is fine but something *it* depends on isn't. +fn preview(rows: &[ParamEditorRow]) -> Vec>> { + let duplicates = duplicate_name_rows(rows); + let mut table = ParameterTable::new(); + let mut set_errors: Vec> = vec![None; rows.len()]; + for (idx, row) in rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + set_errors[idx] = Some(e.to_string()); + } + } + rows.iter() + .enumerate() + .map(|(idx, row)| { + let name = row.name.trim(); + if name.is_empty() { + return None; + } + if duplicates.contains(&idx) { + return Some(Err(format!("duplicate name '{name}'"))); + } + if let Some(err) = &set_errors[idx] { + return Some(Err(err.clone())); + } + Some(table.resolve(name).map_err(|e| e.to_string())) + }) + .collect() +} + +/// Build the named-parameter editor content. `rows` is the live working +/// buffer. +pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let title = text(t!("Named Parameters")).size(15); + let hint = text(t!( + "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." + )) + .size(11) + .style(muted_style); + + let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 }; + const NAME_WIDTH: f32 = 120.0; + const VALUE_WIDTH: f32 = 140.0; + + let head = container( + row![ + container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)), + container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width), + container(text(t!("Value")).size(11).style(muted_style)).width(Length::Fixed(VALUE_WIDTH)), + Space::new().width(Length::Fixed(30.0)), + ] + .spacing(8), + ) + .padding(gutter); + + let results = preview(rows); + let mut list = column![].spacing(3); + for (idx, row) in rows.iter().enumerate() { + let name_box = text_input(t!("name").as_ref(), &row.name) + .on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Name, value: v }) + .size(13) + .padding([3, 6]) + .width(Length::Fixed(NAME_WIDTH)); + let formula_box = text_input(t!("formula").as_ref(), &row.formula) + .on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Formula, value: v }) + .size(13) + .padding([3, 6]) + .width(sizing.width); + let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() { + Some(Ok(value)) => container(text(format!("= {value:.4}")).size(12).style(muted_style)).into(), + Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(), + None => Space::new().into(), + }; + let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) + .on_press(Message::NamedParametersRemove(idx)) + .padding([2, 6]) + .style(button::danger); + list = list.push( + row![name_box, formula_box, container(value_cell).width(Length::Fixed(VALUE_WIDTH)), del] + .spacing(8) + .align_y(iced::Center), + ); + } + + let add = button(text(t!("+ Add parameter")).size(12)) + .on_press(Message::NamedParametersAdd) + .padding([4, 10]) + .style(button::secondary); + + let apply = button(text(t!("Apply")).size(12)) + .on_press(Message::NamedParametersApply) + .padding([4, 16]) + .style(button::primary); + + container( + column![ + title, + hint, + Space::new().height(6), + head, + scrollable(container(list).padding(gutter)).height(sizing.height), + Space::new().height(6), + row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + ] + .spacing(6) + .width(sizing.width) + .height(sizing.height), + ) + .padding(12) + .width(sizing.width) + .height(sizing.height) + .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() }) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(name: &str, formula: &str) -> ParamEditorRow { + ParamEditorRow { name: name.to_string(), formula: formula.to_string() } + } + + #[test] + fn preview_resolves_a_literal_and_a_cross_row_reference() { + let rows = vec![row("hole_dia", "5"), row("hole_spacing", "2 * hole_dia + 1.5")]; + let results = preview(&rows); + assert_eq!(results[0], Some(Ok(5.0))); + assert_eq!(results[1], Some(Ok(11.5))); + } + + #[test] + fn preview_resolves_a_forward_reference_regardless_of_row_order() { + // `plate_width` (row 0) references `plate_len` (row 1) -- defined + // *after* it in the buffer. Order in the editor must not matter, same + // as it doesn't for the real `ParameterTable`. + let rows = vec![row("plate_width", "plate_len / 2"), row("plate_len", "20")]; + let results = preview(&rows); + assert_eq!(results[0], Some(Ok(10.0))); + assert_eq!(results[1], Some(Ok(20.0))); + } + + #[test] + fn preview_reports_a_cycle_on_the_row_that_closes_it() { + let rows = vec![row("a", "b"), row("b", "a")]; + let results = preview(&rows); + // Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle); + // row 1 (b = a) is the one that would close the loop and is rejected. + assert!(matches!(results[0], Some(Ok(_))) || matches!(&results[0], Some(Err(e)) if e.contains("not defined"))); + assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular"))); + } + + #[test] + fn preview_leaves_a_blank_row_without_a_name_unflagged() { + let rows = vec![row("", "")]; + assert_eq!(preview(&rows), vec![None]); + } + + #[test] + fn preview_reports_a_malformed_formula_on_its_own_row() { + let rows = vec![row("bad", "1 +")]; + let results = preview(&rows); + assert!(matches!(&results[0], Some(Err(e)) if e.contains("formula error"))); + } + + #[test] + fn preview_flags_every_row_sharing_a_duplicate_name_instead_of_picking_one() { + let rows = vec![row("x", "1"), row("x", "2"), row("y", "3")]; + let results = preview(&rows); + assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate"))); + assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate"))); + assert_eq!(results[2], Some(Ok(3.0)), "an unrelated row's name must not be affected"); + } +} diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 391974260..0e3957701 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -41,6 +41,7 @@ pub fn view_window<'a>( crosshair_color: Option<[u8; 3]>, crosshair_color_input: &'a str, lineweight_display_scale: i32, + ui_scale: i32, ribbon_label_font_size: i32, ribbon_group_title_font_size: i32, model_space: &'a crate::app::config::ModelSpaceThemeConfig, @@ -349,6 +350,24 @@ pub fn view_window<'a>( }); let mut display = column![ + text(crate::t!("UI Scale")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Overall size")).size(12).width(150), + slider(50..=200, ui_scale.clamp(50, 200), Message::UiScaleChanged) + .step(5) + .width(Fill), + text(format!("{}%", ui_scale.clamp(50, 200))).size(11).width(44), + ] + .spacing(12) + .align_y(iced::Center), + Space::new().height(8), + text(crate::t!( + "Scales the whole interface at once — every panel, dialog, the ribbon, and their text — rather than one widget's font size. Takes effect immediately." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), text(crate::tr!("options", "theme-section")).size(15), Space::new().height(10), row![ diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index a7c474ea8..2c9f025d2 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -5,6 +5,7 @@ // constrained neighbors, produces the expected change list, and an // unrelated edit doesn't touch anything (§4.3's "what does NOT trigger"). +use OpenCADStudio::scene::named_parameters::DrivingValue; use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use OpenCADStudio::scene::{ChangeKind, Scene}; use acadrust::entities::EntityType; @@ -96,7 +97,7 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( ConstraintKind::Distance, vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(20.0), + Some(DrivingValue::Literal(20.0)), ); set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 @@ -107,6 +108,63 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( assert!((len - 20.0).abs() < 1e-6, "length should have been solved to the driving value, got {len}"); } +/// `named_parameters_design.md` stage 3: a `Distance` constraint's driving +/// value can be a named-parameter reference instead of a literal, resolved +/// through `Scene::named_parameters` at solve time. +#[test] +fn distance_constraint_resolves_a_named_parameter_reference() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + scene.named_parameters_mut().set("target_len", "20").unwrap(); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("target_len".to_string())), + ); + + set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 20.0).abs() < 1e-6, "length should have resolved the named parameter, got {len}"); + + // Editing the parameter itself and touching the scope again should + // ripple through, same as editing a literal driving value would. + scene.named_parameters_mut().set("target_len", "8").unwrap(); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter value, got {len}"); +} + +/// A `driving_param` referencing a named parameter that doesn't (or no +/// longer) exists must not panic the solve — same "skip, don't panic" +/// contract `build_constraint`'s doc comment gives every other unbuildable +/// constraint. The referenced line's two endpoint coordinates are still +/// registered (via `point_ref`) even though the constraint itself can't be +/// built, so they're just left free — the edit that triggered this solve is +/// not undone or altered. +#[test] +fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("does_not_exist".to_string())), + ); + + let edited_end = Vector3::new(5.0, 5.0, 0.0); + set_line_end(&mut scene, line, edited_end); + scene.bump_entities(&[(line, ChangeKind::Modified)]); // must not panic + + let (_, end) = line_endpoints(&scene, line); + assert_eq!(end, edited_end, "an unresolvable driving reference must leave the edit alone, not move it toward a phantom target"); +} + #[test] fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() { let mut scene = Scene::new(); @@ -285,8 +343,8 @@ fn two_conflicting_distance_targets_are_reported_as_conflicting() { let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - let first = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(20.0)); - let second = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(50.0)); + let first = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(DrivingValue::Literal(20.0))); + let second = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(DrivingValue::Literal(50.0))); scene.bump_entities(&[(a, ChangeKind::Modified)]); diff --git a/tests/sketch_constraints_xrecord_roundtrip.rs b/tests/sketch_constraints_xrecord_roundtrip.rs index 9c22ce9a9..5446a8959 100644 --- a/tests/sketch_constraints_xrecord_roundtrip.rs +++ b/tests/sketch_constraints_xrecord_roundtrip.rs @@ -5,6 +5,7 @@ // (save/load integration, the "lazy" persistence model) is gated on this // test passing — see the design doc §8 stage 2 and §7 open question 1. +use OpenCADStudio::scene::named_parameters::DrivingValue; use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchConstraintSet, SketchRef, SketchScope}; use OpenCADStudio::scene::Scene; use acadrust::entities::EntityType; @@ -20,9 +21,13 @@ fn sample_set(scope: SketchScope) -> SketchConstraintSet { vec![SketchRef::point(Handle::new(101), 0), SketchRef::point(Handle::new(102), 1)], None, ); - set.add(ConstraintKind::Distance, vec![SketchRef::whole(Handle::new(103))], Some(25.0)); - set.add(ConstraintKind::Angle, vec![SketchRef::whole(Handle::new(104)), SketchRef::whole(Handle::new(105))], Some(45.0)); - set.add(ConstraintKind::Radius, vec![SketchRef::whole(Handle::new(106))], Some(3.5)); + set.add(ConstraintKind::Distance, vec![SketchRef::whole(Handle::new(103))], Some(DrivingValue::Literal(25.0))); + set.add( + ConstraintKind::Angle, + vec![SketchRef::whole(Handle::new(104)), SketchRef::whole(Handle::new(105))], + Some(DrivingValue::Literal(45.0)), + ); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(Handle::new(106))], Some(DrivingValue::Literal(3.5))); set } From 5d93476c01ed67d0ee18ea8fa799e4f2a9b6ccd4 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Wed, 9 Sep 2026 14:48:02 +0200 Subject: [PATCH 05/99] Add DWG-native constraint compatibility design doc Hakan (upstream maintainer) asked for the sketch-constraint system to persist using the constraint object model already exposed in acadrust/ cadcodec (Assoc2dConstraintGroup, AssocConstraintNode), so a file saved by OpenCADStudio carries constraints AutoCAD/BricsCAD recognize as their own native objects, not just an opaque custom XRecord blob. Covers: - The full ConstraintKind -> AssocConstraintNodeData mapping table, grounded in build_constraint's actual refs shapes (the real contract, not the doc comments). - The geometry-node mapping for SketchRef (line/circle/arc/point references) onto Line/BoundedLine/Circle/Arc/ImplicitPoint nodes. - The AssocNetwork -> AssocAction -> Assoc2dConstraintGroup graph shape actually needed, not just a flat data swap. - A proposed additive persistence strategy: write both our own OCS_SKETCH_CONSTRAINTS XRecord and the native graph, since our model carries things (DrivingValue::Named) the native graph can't yet losslessly express. - Known gaps: Equal has no length/radius sub-kind split natively, several AutoCAD constraint types we don't model at all, Tangent involving an Arc (a pre-existing gap in the constraint system, unrelated to DWG compatibility). Three of five open questions are resolved against Autodesk's own primary sources rather than left as inference: the ObjectARX 2012 SDK's own C++ headers (mirrored on GitHub, Autodesk's own copyright notice grants free use/distribution -- it's the SDK Autodesk publishes for third-party interop) and Autodesk's live public ObjectARX reference site. Confirmed: the associative network's dictionary key ("ACAD_ASSOCNETWORK"), the AcDb::ImplicitPointType enum settling how SketchRef's point markers map to constraint geometry nodes, and that AssocVariable's evaluator field can be left empty (Autodesk's own doc: "the default evaluator for current acad version will be used"). Two questions remain and need a real write-then-open test rather than more reading: whether AutoCAD tolerates a partially-populated graph, and DWG version gating on fields not yet checked. Suggested staged approach: prove out Horizontal/Vertical on a single Line first (sidesteps the value-dependency wiring), verify it opens cleanly in real AutoCAD/BricsCAD or a DWG inspector, before writing more of the graph on an unverified foundation. Design only -- no implementation yet. Co-Authored-By: Claude Sonnet 5 --- docs/dwg_constraint_compatibility_design.md | 352 ++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/dwg_constraint_compatibility_design.md diff --git a/docs/dwg_constraint_compatibility_design.md b/docs/dwg_constraint_compatibility_design.md new file mode 100644 index 000000000..9ffa6998c --- /dev/null +++ b/docs/dwg_constraint_compatibility_design.md @@ -0,0 +1,352 @@ +# DWG-Native Constraint Compatibility — Design + +## 1. What this is, and why + +Hakan (upstream maintainer) reviewed the sketch-constraint system +(`docs/parametric_system_design.md`) and asked for it to persist using the +constraint object model already exposed in `acadrust`/`cadcodec` (the pinned +CAD kernel), so a file saved by OpenCADStudio carries constraints AutoCAD or +BricsCAD would recognize as their own native objects — not just an opaque +custom blob only this app understands. + +This is purely a **persistence-format** question, not a solver question. +`ocs_gcs` (the FreeCAD `planegcs` port: `crates/ocs_gcs/src/solvers/{bfgs,dogleg,lm}.rs`) +is the numerical engine that computes geometry from a constraint system — +that stays exactly as it is regardless of what this document decides. What +changes is only how `SketchConstraintSet` gets written to and read from a +`CadDocument`, in `src/scene/sketch_persist.rs`, currently a custom +`bincode`-in-XRecord blob keyed `OCS_SKETCH_CONSTRAINTS`. + +## 2. What `acadrust` already provides (confirmed from source) + +All paths below are relative to the pinned checkout +(`~/.cargo/git/checkouts/cadcodec-*/*/src/`, rev pinned in `Cargo.toml`). + +- `objects/associative.rs` defines AutoCAD's real 2D geometric-constraint + object graph as plain Rust types, reachable generically through + `ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(Assoc2dConstraintGroup), .. })` + — the same `document.objects: HashMap` map + `sketch_persist.rs` already inserts `XRecord`/`Dictionary` entries into. +- Both DWG (`io/dwg/dwg_stream_{readers,writers}/object_{reader,writer}/associative.rs`, + ~1240/~870 lines) and DXF (`io/dxf/{reader,writer}/section_{reader,writer}/associative.rs`) + have **complete read and write** implementations for every type below — + confirmed by reading the actual field-by-field (de)serialization code, not + inferred from the type definitions alone. +- `classes/mod.rs` registers the DXF class names every one of these needs + when written (`(DXF_NAME, "AcDbClassName", ...)` tuples): + `AcDbAssocDependency`, `AcDbAssocGeomDependency`, `AcDbAssocAction`, + `AcDbAssocNetwork`, `AcDbAssoc2dConstraintGroup`, `AcDbAssocVariable`, + `AcDbAssocPersSubentManager`. + +### The graph shape + +Not a flat list — a small object graph, matching AutoCAD's real associative +framework: + +``` +AssocNetwork (per block/space that owns constrained geometry) + └─ owned_actions: Vec ─→ AssocAction + ├─ action_body: Handle ─→ Assoc2dConstraintGroup + │ ├─ work_plane: [Vector3; 3] + │ └─ nodes: Vec + │ (both the *constraint* nodes + │ and the *geometry* nodes live + │ in this one Vec — see §3/§4) + └─ dependencies: Vec + ─→ AssocGeomDependency + ├─ dependency: AssocDependency + │ └─ dependent_on: Handle + │ (the real Line/Circle/... entity) + └─ persistent_subent: AssocPersistentSubentId +``` + +`AssocConstraintNode { node_id: i32, status: u8, connections: Vec, +class_name: String, registry_flag: bool, data: AssocConstraintNodeData }` — +`node_id`/`connections` link nodes to each other *within* the group (a +constraint node's `owner_id` fields point at a geometry node's `node_id`); +`class_name` picks which `AssocConstraintNodeData` variant applies on +read (`read_constraint_node_data(reader, class_name)`, +`dwg_stream_readers/object_reader/associative.rs:663`). + +### 2b. Primary sources beyond `cadcodec`'s own code + +`cadcodec` ships no constraint-bearing test fixture and its own source only +shows *what bytes get read*, not *what they mean* semantically — §7 (now +mostly resolved) needed Autodesk's own interface documentation to answer. +Two real, first-party sources turned out to be publicly available with no +sample DWG file needed at all: + +- **The ObjectARX 2012 SDK's own C++ headers**, mirrored on GitHub at + [Stalso/CadPlugin](https://github.com/Stalso/CadPlugin/tree/master/ObjectARX%202012/inc) + — Autodesk's own copyright header on each file explicitly grants "permission + to use, copy, modify, and distribute this software... for any purpose and + without fee", and this is the SDK Autodesk itself publishes specifically so + third parties can build interoperable tools — not leaked or decompiled + source. `AcDbAssoc2dConstraintGroup.h`, `AcDbAssocNetwork.h`, + `AcDbAssocManager.h`, `AcDbAssocVariable.h`, `AcConstrainedGeometry.h` were + all read directly; their class-level doc comments describe the exact + same object graph `cadcodec` implements, in Autodesk's own words. +- **Autodesk's live public ObjectARX reference** + (`help.autodesk.com/view/OARXMAC/2025/...`) — confirmed the + `AcDb::ImplicitPointType` enum's exact values and per-member descriptions + (used for §4's `point_type` mapping) and `AcDbAssocNetwork`'s + `getInstanceFromObject`/`getInstanceFromDatabase` dictionary-key default + (used for §7 question 1). No login or SDK download needed — the reference + guide is indexed and publicly browsable. + +## 3. Constraint-kind mapping + +Our `ConstraintKind` (`src/scene/sketch_constraints.rs`) against +`AssocConstraintNodeData` (`objects/associative.rs:885`). The "refs shape" +column is copied from `sketch_solve::build_constraint`'s actual `match` +(`src/scene/sketch_solve.rs:151-208`) — the ground truth for what each kind +expects, not the doc comment. + +| `ConstraintKind` | refs shape (from `build_constraint`) | `AssocConstraintNodeData` | Notes | +|---|---|---|---| +| `Coincident` | `[point, point]` | `Geometrical` + `class_name = "ACPOINTCOINCIDENCECONSTRAINT"` | | +| `Horizontal` | `[whole_line]` | `Geometrical` + `"ACHORIZONTALCONSTRAINT"` | | +| `Vertical` | `[whole_line]` | `Geometrical` + `"ACVERTICALCONSTRAINT"` | | +| `Parallel` | `[whole_line, whole_line]` | `Parallel { datum_line_index: None, .. }` | dedicated variant, not `Geometrical` | +| `Perpendicular` | `[whole_line, whole_line]` | `Geometrical` + `"ACPERPENDICULARCONSTRAINT"` | | +| `Equal` (line case) | `[whole_line, whole_line]` | `Geometrical` + `"ACEQUALLENGTHCONSTRAINT"` | | +| `Equal` (circle/arc case) | `[whole_circle, whole_circle]` | `Geometrical` + `"ACEQUALRADIUSCONSTRAINT"` | **our `Equal` doesn't distinguish these — see §5** | +| `Distance` | `[point, point]` | `Distance { direction_type, distance: Option, value_dependency, dimension_dependency, .. }` | needs an `AssocVariable`/value dependency for the driving value — see §5 | +| `Angle` | `[whole_line, whole_line]` | `Angle { sector_type, value_dependency, dimension_dependency, .. }` | same value-dependency need | +| `Radius` | `[whole_circle]` | `RadiusDiameter { mode, value_dependency, dimension_dependency, .. }` | `mode` presumably distinguishes radius vs. diameter — we're always radius | +| `Tangent` | `[circle, circle]` or `[circle, line]`/`[line, circle]` — Line-Line and anything involving an `Arc` are explicitly unsupported today (`sketch_solve.rs:239-241`, `Arc` isn't a supported `EntityGeom` variant yet) | `Geometrical` + `"ACTANGENTCONSTRAINT"` | our own `Arc`-tangent gap predates this document, unrelated to DWG compatibility | + +AutoCAD's `is_plain_geometrical_constraint` list also includes several kinds +we have no `ConstraintKind` for at all: `ACCENTERPOINTCONSTRAINT`, +`ACCOLINEARCONSTRAINT`, `ACCONCENTRICCONSTRAINT`, `ACEQUALCURVATURECONSTRAINT`, +`ACEQUALDISTANCECONSTRAINT`, `ACEQUALHELPPARAMETERCONSTRAINT`, +`ACFIXEDCONSTRAINT`, `ACMIDPOINTCONSTRAINT`, `ACNORMALCONSTRAINT`, +`ACPOINTCURVECONSTRAINT`, `ACSYMMETRICCONSTRAINT` — genuinely nothing to map +to yet, not a mapping gap (§5). + +## 4. Geometry-node mapping + +Every entity a constraint references also needs its own node in the same +`nodes: Vec`, so constraint nodes have something to +point `owner_id` at, *and* a paired `AssocGeomDependency` (in the owning +`AssocAction`'s `dependencies`) so the graph — not just the constraint data +— actually references the real `Line`/`Circle`/... entity by `Handle`. + +| Our reference | `AssocConstraintNodeData` geometry variant | +|---|---| +| `SketchRef::whole(line)` | `BoundedLine { point, direction, is_ray: false, start_point, end_point, .. }` — confirmed a *bounded* line is the right choice: `Line`/`BoundedLine`'s split exists because AutoCAD's own constrained-geometry set includes true infinite construction lines (`ACCONSTRAINEDCONSTRUCTIONLINE`/`ACCONSTRAINEDDATUMLINE`, `read_constraint_node_data`, `associative.rs:711-714`) as a *separate* case from an ordinary bounded segment; nothing we draw is an infinite line | +| `SketchRef::point(line, 0\|1)` | `ImplicitPoint { point_type: kStartImplicit(0)\|kEndImplicit(1), point_index: -1, curve_id: , .. }` — **confirmed**, `AcDb::ImplicitPointType` enum, `kStartImplicit`/`kEndImplicit`/`kMidImplicit`/`kCenterImplicit`/`kDefineImplicit` (0-4), Autodesk's own live ObjectARX reference (§2b). `point_index` is `-1` for every case except `kDefineImplicit` (a spline control point, which we don't reference); `curve_id` is presumably the owning geometry node's own `node_id` (not yet cross-checked against a real file — the *values* are confirmed, this one wiring detail isn't) | +| `SketchRef::whole(circle)` | `Circle { center, normal, direction, radius, start_parameter: 0.0, end_parameter: TAU, .. }` | +| `SketchRef::whole(arc)` | `Arc { .. same fields .. , start_point, end_point }` | +| `SketchRef::center(circle\|arc)` | `ImplicitPoint { point_type: kCenterImplicit(3), point_index: -1, curve_id: , .. }` — confirmed by the same enum (`kCenterImplicit`: "Center point of a circle, arc, ellipse or bounded ellipse") — **not** the separate `Point`/`ACCONSTRAINEDPOINT` variant, which the ObjectARX docs' own class hierarchy (§2b) show is for a *free-standing* constrained point, not one implicitly defined by another curve | +| `SketchRef::point(_, -2)` (reserved, unused today) | would be `kMidImplicit` if ever used — "Mid point of a bounded line ... or arc" is exactly what marker `-2`'s doc comment already reserved this for | + +`SketchRef` and this graph already agree on one thing directly: our own doc +comment (`src/scene/sketch_constraints.rs:20-22`) says the `marker` +convention was deliberately borrowed from +`AssocDimensionReference::main_gs_marker` (`src/scene/dimension_assoc.rs`, +already shipping, already reading/writing real `Assoc*` objects for +associative dimensions). That's a different, lighter mechanism than the +constraint graph (an `AcDbOsnapPointRef` chain attached directly to a +`Dimension` entity, not the `AssocNetwork`/`AssocAction` framework — see +`AssocDimensionAssociation`, `objects/associative.rs:1200`), so it's a +precedent for *how this codebase already talks to `Assoc*` types*, not a +template for the constraint graph's own wiring. + +## 5. Known gaps (not addressed by this document) + +- **`Equal` is one `ConstraintKind`, AutoCAD has (at least) two**: + `ACEQUALLENGTHCONSTRAINT` (lines) vs. `ACEQUALRADIUSCONSTRAINT` + (circles/arcs), plus `ACEQUALCURVATURECONSTRAINT`/ + `ACEQUALDISTANCECONSTRAINT` we don't model at all. `build_constraint` + already branches on `refs`' resolved shape at solve time + (`sketch_solve.rs:181-188`), so the same branch can pick the write-side + `class_name` — no data-model change needed for the length/radius split; + the other two AutoCAD sub-kinds are a real, separate gap. +- **`DrivingValue::Named` — confirmed, has a real native equivalent, and the + full wiring is now traced end to end.** + `AcDbAssocVariable` (`objects/associative.rs:1024`: `name`, `expression`, + `evaluator`, `description`, cached `value`) is fully read *and* written on + both DWG and DXF (confirmed in + `dwg_stream_readers/object_reader/associative.rs:1031-1063` and the DXF + reader/writer equivalents) — it's the same associative-action graph a + constraint group lives in (`AssocVariable` wraps an `AssocAction`, exactly + like `Assoc2dConstraintGroup` does), not a separate table. + + The two handles on `Distance`/`Angle`/`RadiusDiameter` + (`read_explicit_constraint`, `associative.rs:652-661` — reads them in this + order: `value_dependency` first, `dimension_dependency` second) resolve + to two different, unrelated things: + + - **`value_dependency`** → an `AssocValueDependency` object + (`{ dependency: AssocDependency, name, value: AssocEvalVariant }`, + `objects/associative.rs:554`). Its own `value: AssocEvalVariant` + (`{ code: i16 /* DXF resbuf type */, value: AssocEvalValue::Real(f64) }`, + confirmed `associative.rs:579-596`) is the **cached, already-resolved + number** — always present, read fast without evaluating anything. The + embedded `AssocDependency.dependent_on: Handle` + (`read_dependency`, `associative.rs:83-109`) is what actually links to a + named parameter: `Handle::NULL` when the driving value is a bare + literal, or an `AssocVariable`'s handle when it's driven by one. This + gives a direct, confirmed mapping: + - `DrivingValue::Literal(v)` → `AssocValueDependency` with + `value = Real(v)`, `dependency.dependent_on = Handle::NULL`. + - `DrivingValue::Named(name)` → the same, but `dependency.dependent_on` + points at an `AssocVariable{ name, expression: , .. }` + object (create once per distinct name, reused by every constraint + referencing it — `value` still carries the current resolved number, + kept in sync with `ParameterTable`'s last evaluation, same duty our + own `dof`/`conflicts` caches already have). + - **`dimension_dependency`** → a *separate* dependency chain + (`AssocAnnotationDependency`/`AssocDimDependencyBody`, + `associative.rs:744-751` and `:1077-1082`) linking to the actual + `Dimension` entity AutoCAD draws next to a constraint to show its + current value on screen — nothing to do with named parameters at all. + Not required for our purposes unless we also want the constraint to + carry a visible on-canvas dimension the way AutoCAD's does (open + question, not yet decided — §7). + + Remaining gap before mapping directly: AutoCAD's `expression` is + evaluated by a named, extensible `evaluator` (not fixed) — real-file + survey needed to know what evaluator name(s) actually appear in practice + (§7 open question 4), ours has one hardcoded evaluator. +- **Missing `ConstraintKind`s**: Concentric, Colinear, Symmetric, Fixed, + Midpoint, Normal, CenterPoint, PointOnCurve — real AutoCAD constraint + types with no `ConstraintKind` variant to map from at all. Out of scope + until/unless the sketch-constraint system itself grows them. +- **`Tangent` involving an `Arc`** has no native mapping to design, since + `build_constraint` doesn't support it at all yet (`sketch_solve.rs:239-241` + — `Arc` isn't a supported `EntityGeom` variant in that function). A + pre-existing gap in the constraint system itself, unrelated to and not + blocking this document. + +## 6. Persistence architecture: additive, not a replacement + +**Proposal: write both.** Keep `OCS_SKETCH_CONSTRAINTS` as the authoritative +round-trip format OpenCADStudio itself reads back +(`sketch_persist::load_sketch_constraints_from_document`), and *additionally* +materialize the `AssocNetwork`/.../`Assoc2dConstraintGroup` graph alongside +it on save, for other applications to read. + +Reasoning: +- Our own model carries information the native graph can't represent + losslessly today — `DrivingValue::Named` chief among them (§5), also + `enabled` (a suppressed-but-kept constraint) which may or may not have a + clean native equivalent (`AssocConstraintNodeData`'s `is_active` field on + several variants looks like the right fit, but unconfirmed, §7). + Round-tripping through the native format only and reconstructing our + model from it risks silent data loss on every save/reopen cycle. +- The native graph's own geometry-node fields (`center`, `radius`, `point`, + ...) are *snapshots* of the current solved geometry, not derived at load + time — so they need to be kept in sync with whatever `ocs_gcs` last + solved, same as we already do for our own tessellated wire geometry. Two + representations of "the same" constraint, kept consistent by one save + path, is a smaller invariant to maintain than trying to make the native + graph the *only* source of truth for a system it can't fully express. +- This is reversible: if the native graph turns out to work well enough + (once tested against real AutoCAD/BricsCAD opens, §7) to *become* the + primary format later, dropping the redundant `OCS_SKETCH_CONSTRAINTS` + write is a small follow-up, not a rewrite. + +## 7. Open questions + +Three of the original five are now resolved from Autodesk's own primary +sources (§2b), not just inferred from `cadcodec`'s struct shapes — kept +here with their answers so the reasoning stays visible, not deleted. +`cadcodec` itself still ships no constraint-bearing DWG/DXF fixture and no +constraint-specific test (checked: no hits for `Assoc2dConstraintGroup`/ +`AssocConstraintNode` under any `tests/` directory or fixture path in the +dependency), so even the resolved ones haven't been checked against a real +written-and-reopened file yet — that step still matters (§8 stage 1) for +confirming implementation correctness, just not for the *design* questions +below anymore. + +1. **Resolved.** Where the top-level `AssocNetwork` attaches: + `AcDbAssocNetwork::getInstanceFromDatabase`/`getInstanceFromObject`'s own + doc comments (§2b) state the default sub-dictionary key directly — + `"ACAD_ASSOCNETWORK"` — used both for the one top-level network (owned by + the database's Named Object Dictionary) and for each per-block-record + network (owned by that `AcDbBlockTableRecord`'s own extension + dictionary — confirming the parallel to `OCS_SKETCH_CONSTRAINTS`'s own + attachment was right). The two are linked: creating a per-block network + with `addToTopLevelNetwork = true` (the default) also registers it as an + action inside the top-level network, so evaluating the top-level network + cascades through every block's sub-network. +2. **Resolved.** Sub-entity addressing for `ImplicitPoint`: confirmed the + real addressing lives entirely in the constraint-node's own + `point_type`/`point_index`/`curve_id` fields, not in + `AssocPersistentSubentId` (that type turned out to be an abstract C++ + base class for a *different*, more general subentity-tracking need + elsewhere in the associative framework — irrelevant to 2D constraint + geometry). `AcDb::ImplicitPointType`'s exact enum (§4) settles the + mapping. +3. **Still open.** Whether a partially-populated graph (e.g. omitting + `AssocPersSubentManager`, or leaving some `AssocAction` bookkeeping + fields at their `Default`) still opens cleanly in real AutoCAD, or + whether it requires every field correctly populated to avoid a repair + prompt or silent constraint loss. Genuinely needs a real write-then-open + test, not documentation — this is a robustness/tolerance question, not a + structural one. +4. **Narrowed to almost nothing.** `AssocVariable`'s `evaluator` field: + `AcDbAssocVariable.h`'s own doc comment (§2b) says plainly — "If empty, + the default evaluator for current acad version will be used." Leaving it + as an empty string is officially-supported behavior per the SDK itself, + not a guess. No further investigation needed unless a real file later + shows this doesn't round-trip as expected. +5. **Still open.** DWG version gating — `AssocConstraintNode`'s own reader + already branches on `version.r2013_plus(dxf_version)` for one field + (`dwg_stream_readers/object_reader/associative.rs:604`); unclear whether + other fields need per-version handling this document hasn't looked for. + Likely answerable the same way as questions 1/2/4 were (Autodesk's own + docs usually note version-specific members) — just not done yet. +6. **Still open, low priority.** Whether to bother wiring + `dimension_dependency` at all (§5) — it drives the small on-canvas + dimension label AutoCAD shows next to a constrained value, cosmetic + rather than structural. Leaving it `Handle::NULL` is presumably safe (the + constraint itself is still fully defined without it) but unconfirmed; + deferring this to a later stage either way (§8). + +## 8. Suggested staged approach + +1. **Proof of concept, narrowest possible slice**: `Horizontal`/`Vertical` + on a single `Line`, no dimensional constraints (sidesteps the + `AssocVariable`/value-dependency wiring entirely, though that's now fully + designed too — §5). Write the graph — `AssocNetwork` at + `"ACAD_ASSOCNETWORK"` on the scope owner's extension dictionary (§7.1), + one `Assoc2dConstraintGroup` action, one `BoundedLine` geometry node, one + `Geometrical`/`"ACHORIZONTALCONSTRAINT"` (or vertical) constraint node — + and open the file in whatever real-DWG-reading tool is available, to + confirm AutoCAD/BricsCAD actually shows a constraint icon on the line and + doesn't flag the file as corrupt. This is what answers open question 3 + (partial-graph tolerance) — the one remaining question documentation + alone can't settle. +2. **Geometric constraints, still no driving value**: add `Coincident` + (needs `ImplicitPoint`/`kStartImplicit`/`kEndImplicit`, §4, now + confirmed), `Parallel`, `Perpendicular`, `Equal` (both sub-kinds) — + verify against the same real-file test from stage 1. +3. **Dimensional constraints**: `Distance`, `Angle`, `Radius`, using the now + fully-traced `value_dependency → AssocValueDependency → AssocVariable` + chain (§5) — including deciding whether `DrivingValue::Named` gets the + real native mapping designed there, or stays a resolved-literal degrade + for a first pass. +4. **Everything else**: `Tangent`'s non-circle-circle cases, the missing + `ConstraintKind`s (§5) if/when the sketch-constraint system grows them, + `dimension_dependency` (§7.6) if a visible on-canvas dimension label + turns out to matter. + +Each stage's file should be hand-inspected (or opened in real software) +before moving to the next — the graph's correctness genuinely can't be +verified by this codebase's own round-trip tests alone, since a bug that's +symmetric (write it wrong, read the same wrongness back) would pass here and +still fail to open correctly elsewhere. + +## 9. Progress + +Design only — no implementation yet. Of the original five open questions, +three are now resolved from Autodesk's own primary documentation (§7.1, 7.2, +7.4) rather than inference — the network's dictionary key, `ImplicitPoint`'s +addressing enum, and the `AssocVariable` evaluator default are no longer +guesses. Two remain (§7.3 partial-graph tolerance, §7.5 version gating) and +both genuinely need a real write-then-open test rather than more reading. +Next action per §8 is stage 1's narrowest slice — the design for it no +longer rests on an unconfirmed foundation, only on an unverified one. From cf33a1d019306999d5f6c1bd56ca876e33d7c575 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Wed, 9 Sep 2026 17:26:53 +0200 Subject: [PATCH 06/99] Add DWG/DXF-native constraint persistence alongside the XRecord format Implements docs/dwg_constraint_compatibility_design.md's staged plan in full: materializes the sketch-constraint set as acadrust's real AssocNetwork/Assoc2dConstraintGroup object graph on save, additive to the existing OCS_SKETCH_CONSTRAINTS XRecord, so files also carry constraints AutoCAD/BricsCAD recognize as their own native objects (per Hakan's request on the upstream project). Covers every ConstraintKind, including AssocVariable wiring for named-parameter driving values, and clears+rebuilds on each save. DWG round-trips the full dependency chain; DXF only keeps the constraint-group shell since acadrust's own DXF writer deliberately omits AssocDependency-family objects. Both behaviors are covered by tests and documented in the design doc's progress section, along with two other implementation-time findings (skipping the NOD-anchored top-level network, and the DWG writer's root-node/connections wire format). Co-Authored-By: Claude Sonnet 5 --- docs/dwg_constraint_compatibility_design.md | 78 +- src/app/update/file.rs | 5 + src/scene/dwg_native_constraints.rs | 1008 +++++++++++++++++++ src/scene/mod.rs | 1 + 4 files changed, 1084 insertions(+), 8 deletions(-) create mode 100644 src/scene/dwg_native_constraints.rs diff --git a/docs/dwg_constraint_compatibility_design.md b/docs/dwg_constraint_compatibility_design.md index 9ffa6998c..59fa22b74 100644 --- a/docs/dwg_constraint_compatibility_design.md +++ b/docs/dwg_constraint_compatibility_design.md @@ -342,11 +342,73 @@ still fail to open correctly elsewhere. ## 9. Progress -Design only — no implementation yet. Of the original five open questions, -three are now resolved from Autodesk's own primary documentation (§7.1, 7.2, -7.4) rather than inference — the network's dictionary key, `ImplicitPoint`'s -addressing enum, and the `AssocVariable` evaluator default are no longer -guesses. Two remain (§7.3 partial-graph tolerance, §7.5 version gating) and -both genuinely need a real write-then-open test rather than more reading. -Next action per §8 is stage 1's narrowest slice — the design for it no -longer rests on an unconfirmed foundation, only on an unverified one. +**Implemented** (`src/scene/dwg_native_constraints.rs`, wired into both save +paths in `src/app/update/file.rs` alongside the existing +`materialize_sketch_constraints_for_save`/`materialize_named_parameters_for_save` +calls). All 4 stages from §8 landed together rather than incrementally, since +`ConstraintKind` only has the 10 variants already covered — there was no +narrower slice left once Horizontal/Vertical's plumbing (network/dictionary +creation, the two-pass builder, class-table registration) was in place. +Covers: every `ConstraintKind` (`Coincident`, `Horizontal`, `Vertical`, +`Parallel`, `Perpendicular`, `Equal`'s line/circle split, `Distance`, +`Angle`, `Radius`, `Tangent`), `AssocGeomDependency` for whole-geometry nodes, +`AssocValueDependency`/`AssocVariable` for dimensional constraints including +`DrivingValue::Named`, and a resave-clears-and-rebuilds cleanup pass +(`remove_owned_recursive`) so materializing twice doesn't accumulate orphans. +Verified via real `save_to_bytes`/`load_bytes` round trips through +`acadrust`'s own reader/writer (5 tests in that module) — this confirms +self-consistency with `acadrust`'s implementation of the documented format, +**not** real-world AutoCAD/BricsCAD compatibility (§7.3/§7.5 below remain +unverified; no such software is available in this environment). + +Two implementation-time findings not anticipated by the design above: + +- **The top-level NOD-anchored network was dropped from scope.** + `named_parameters_persist.rs`'s own doc comment already documents a real + `acadrust` bug: a `Dictionary`-type object (like the database's Named + Object Dictionary) used as an XRecord/dictionary owner loses its extension + data through a DXF save specifically (`CadDocument::ensure_xrecord` only + patches the owner's `xdictionary_handle` when `get_entity_mut(owner)` + succeeds — a Dictionary isn't an entity, so it silently falls back to a + DWG-only side map). Rather than hit that bug, this module only creates a + network per `SketchScope::owner_handle()` (a real `BlockRecord` entity) — + the same already-proven-safe attachment point `sketch_persist.rs` uses. No + top-level network is created at all. +- **DXF cannot carry the dependency chain — DWG is the only fully-faithful + format.** Confirmed by reading `acadrust`'s DXF writer + (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`): it + deliberately skips writing `AssocDependency`/`AssocValueDependency`/ + `AssocGeomDependency`/`AssocVariable` objects to DXF at all + ("Unrestorable associative-framework objects are not written") — DWG has + no equivalent filter. A DXF save keeps the `Assoc2dConstraintGroup`/ + `AssocNetwork` shell and every node's own embedded geometry, but loses live + geometry/value linkage and every `AssocVariable`. This module still + materializes the same graph for both formats (the shell that survives DXF + is strictly more than nothing, and dangling handles are an ordinary, + tolerated pattern in both formats) rather than special-casing DXF to write + less. Demonstrated directly by + `a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable` + vs. `a_dxf_save_keeps_only_the_constraint_group_shell` in that module's + test suite. + +Also confirmed while implementing (not previously documented): the DWG +writer's `Assoc2dConstraintGroup` arm only emits node data at all when +`nodes.first()` is `Some` — an empty `nodes` `Vec` skips node serialization +entirely — so `nodes[0]` must always be a synthetic root pseudo-node (empty +`class_name`, connected to every real node) even for a single-constraint +graph; and a multi-geometry constraint (e.g. `Parallel`) references its +second geometry via the generic `connections: Vec` field on +`AssocConstraintNode`, not a second dedicated field on +`AssocConstraintNodeData::Parallel` — confirmed from `AcConstraintGroupNode`'s +own doc comment ("the connection between two nodes is not directed"). + +Of the original five open questions, three were resolved from Autodesk's own +primary documentation (§7.1, 7.2, 7.4) before implementation started — the +network's dictionary key, `ImplicitPoint`'s addressing enum, and the +`AssocVariable` evaluator default were confirmed, not guessed. Two remain +open (§7.3 partial-graph tolerance, §7.5 version gating) and still genuinely +need a real AutoCAD/BricsCAD open, not just more reading — self-consistency +through `acadrust`'s own round trip can't settle either one. Next action is +opening a saved DWG in real software (or at minimum a third-party DWG +inspector) to make progress on those two, and posting the GitHub Discussion +Hakan asked for once that's done. diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 8a9640335..1b2cc5063 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1668,6 +1668,10 @@ pub(super) fn on_open_file(&mut self) -> Task { // named_parameters_design.md stage 2: same save-time hook, for the // document-wide parameter table. self.tabs[i].scene.materialize_named_parameters_for_save(); + // docs/dwg_constraint_compatibility_design.md: additive DWG/DXF- + // native constraint graph, alongside (not instead of) the XRecord + // above — same save-time hook. + self.tabs[i].scene.materialize_dwg_native_constraints_for_save(); } #[cfg(not(target_arch = "wasm32"))] @@ -2620,6 +2624,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.sync_solid_models_for_save(i); self.tabs[i].scene.materialize_sketch_constraints_for_save(); self.tabs[i].scene.materialize_named_parameters_for_save(); + self.tabs[i].scene.materialize_dwg_native_constraints_for_save(); let tab_id = self.tabs[i].id; let bounds = crate::ui::wrap_bar::dropdown_bounds( crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs new file mode 100644 index 000000000..3b97b4e89 --- /dev/null +++ b/src/scene/dwg_native_constraints.rs @@ -0,0 +1,1008 @@ +//! Additive DWG/DXF-native constraint persistence +//! (`docs/dwg_constraint_compatibility_design.md`). +//! +//! Writes the `acadrust`-modeled `AssocNetwork`/`AssocAction`/ +//! `Assoc2dConstraintGroup` object graph alongside — not instead of — the +//! existing `OCS_SKETCH_CONSTRAINTS` XRecord (`sketch_persist.rs`), so a +//! saved file also carries constraints AutoCAD/BricsCAD recognize as their +//! own native objects. `sketch_persist.rs` stays the format this app reads +//! back on open; this module is one-way (write-only) for now — see the +//! design doc §6 for why (our model can carry things, like +//! `DrivingValue::Named`, the native graph doesn't perfectly round-trip +//! today) and §9 for what's confirmed vs. still unverified against real +//! AutoCAD/BricsCAD (nothing here has been opened in either — verification +//! so far is a real DWG+DXF round trip through `acadrust`'s own reader, +//! which confirms self-consistency, not real-world compatibility). +//! +//! **DWG is the only format this graph round-trips through with its +//! dependency chain intact.** Confirmed by reading `acadrust`'s own DXF +//! writer (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`, +//! consulted from its `ObjectType::Associative` write arm): it deliberately +//! does not write `AssocDependency`/`AssocValueDependency`/ +//! `AssocGeomDependency`/`AssocVariable` objects to DXF at all +//! ("Unrestorable associative-framework objects are not written") — DWG has +//! no equivalent filter. A DXF save still keeps the +//! `Assoc2dConstraintGroup`/`AssocNetwork` shell and every constraint node's +//! own embedded geometry (real coordinates, written directly on the node — +//! not lost), but each node's `geometry_dependency`/`value_dependency` +//! handle points at nothing once reloaded, and no `AssocVariable` survives. +//! This module still materializes the same graph for both formats rather +//! than special-casing DXF to write less: the shell that does survive is +//! strictly more than nothing, dangling handles are an ordinary, tolerated +//! pattern in both formats (e.g. a reference to a purged object), and +//! working around an upstream limitation by patching `acadrust` is out of +//! scope (this project's stated preference — see `named_parameters_persist.rs`). +//! See the test module for the DWG-vs-DXF difference demonstrated directly. + +use acadrust::objects::{ + Assoc2dConstraintGroup, AssocAction, AssocConstraintNode, + AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant, + AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency, + AssocVariable, AssociativeData, AssociativeObject, ObjectType, +}; +use acadrust::types::{Handle, Vector3}; +use acadrust::{CadDocument, EntityType}; +use rustc_hash::FxHashMap; + +use super::named_parameters::{DrivingValue, ParameterTable}; +use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; +use super::Scene; + +/// The default sub-dictionary key `AcDbAssocNetwork::getInstanceFromObject` +/// uses when none is given — confirmed from Autodesk's own ObjectARX +/// reference (design doc §2b/§7.1), not guessed. +const NETWORK_DICTIONARY_KEY: &str = "ACAD_ASSOCNETWORK"; + +/// `AcConstraintGroupNode::GroupNodeId::kNullGroupNodeId` — node id 0 is +/// reserved as "no node", so real node ids start at 1. +const FIRST_NODE_ID: i32 = 1; + +/// `AcDb::ImplicitPointType` (design doc §4/§7.2) — confirmed from +/// Autodesk's live ObjectARX reference. +mod implicit_point_type { + pub const START: u8 = 0; + pub const END: u8 = 1; + #[allow(dead_code)] // reserved for SketchRef marker -2, not wired up yet + pub const MID: u8 = 2; + pub const CENTER: u8 = 3; +} + +/// One entity's constraint-group presence: its geometry node's id, plus +/// whichever `ImplicitPoint` nodes have been created for it so far, keyed +/// by our own `SketchRef::marker` convention so a second constraint +/// referencing the same point reuses the same node instead of duplicating it. +#[derive(Default)] +struct EntityNodes { + geometry_node_id: i32, + points: FxHashMap, +} + +/// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, +/// allocating node ids and deduplicating geometry/point nodes per entity. +struct GroupBuilder<'a> { + document: &'a CadDocument, + nodes: Vec, + next_node_id: i32, + entities: FxHashMap, + /// Real entity handles that ended up with a geometry node — what + /// `AssocGeomDependency` objects get created for, in first-touch order. + referenced_entities: Vec, +} + +impl<'a> GroupBuilder<'a> { + fn new(document: &'a CadDocument) -> Self { + Self { + document, + nodes: Vec::new(), + next_node_id: FIRST_NODE_ID, + entities: FxHashMap::default(), + referenced_entities: Vec::new(), + } + } + + fn alloc_node_id(&mut self) -> i32 { + let id = self.next_node_id; + self.next_node_id += 1; + id + } + + fn push_node(&mut self, node_id: i32, class_name: &'static str, data: AssocConstraintNodeData) { + self.nodes.push(AssocConstraintNode { + node_id, + status: 1, + connections: Vec::new(), + class_name: class_name.to_string(), + registry_flag: true, + data, + }); + } + + /// The geometry node id for `entity`'s whole shape, creating a `Line` + /// (well: `BoundedLine`, §4 — nothing we draw is an infinite + /// construction line), `Circle`, or `Arc` node the first time this + /// entity is referenced. Returns `None` for an entity kind this pass + /// doesn't support as constraint geometry. + fn geometry_node(&mut self, handle: Handle) -> Option { + if let Some(existing) = self.entities.get(&handle) { + if existing.geometry_node_id != 0 { + return Some(existing.geometry_node_id); + } + } + let entity = self.document.get_entity(handle)?; + let node_id = self.alloc_node_id(); + match entity { + EntityType::Line(l) => { + let point = l.start; + let direction = (l.end - l.start).normalize(); + self.push_node( + node_id, + "AcDbAssocGeomDependency", // placeholder, overwritten below + AssocConstraintNodeData::None, + ); + let last = self.nodes.last_mut().expect("just pushed"); + last.class_name = "ACCONSTRAINEDBOUNDEDLINE".to_string(); + last.data = AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point, + direction, + is_ray: false, + start_point: l.start, + end_point: l.end, + }; + } + EntityType::Circle(c) => { + let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); + self.push_node( + node_id, + "ACCONSTRAINEDCIRCLE", + AssocConstraintNodeData::Circle { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: c.center, + normal: c.normal, + direction: Vector3::new(axis_x.0, axis_x.1, axis_x.2), + radius: c.radius, + start_parameter: 0.0, + end_parameter: std::f64::consts::TAU, + reserved: 0.0, + }, + ); + } + EntityType::Arc(a) => { + let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((a.normal.x, a.normal.y, a.normal.z)); + let direction = Vector3::new(axis_x.0, axis_x.1, axis_x.2); + let start_point = a.center + + Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.start_angle.cos()) + + Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.start_angle.sin()); + let end_point = a.center + + Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.end_angle.cos()) + + Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.end_angle.sin()); + self.push_node( + node_id, + "ACCONSTRAINEDARC", + AssocConstraintNodeData::Arc { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: a.center, + normal: a.normal, + direction, + radius: a.radius, + start_parameter: a.start_angle, + end_parameter: a.end_angle, + reserved: 0.0, + start_point, + end_point, + }, + ); + } + _ => return None, + } + self.entities.entry(handle).or_default().geometry_node_id = node_id; + if !self.referenced_entities.contains(&handle) { + self.referenced_entities.push(handle); + } + Some(node_id) + } + + /// The `ImplicitPoint` node id for one marker on `entity` (line + /// start/end, circle/arc center — design doc §4's confirmed + /// `AcDb::ImplicitPointType` mapping), creating it the first time this + /// exact (entity, marker) pair is referenced. + fn point_node(&mut self, handle: Handle, marker: i32) -> Option { + if let Some(existing) = self.entities.get(&handle).and_then(|e| e.points.get(&marker)) { + return Some(*existing); + } + let curve_id = self.geometry_node(handle)?; + let point_type = match marker { + 0 => implicit_point_type::START, + 1 => implicit_point_type::END, + -3 => implicit_point_type::CENTER, + _ => return None, + }; + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "ACCONSTRAINEDIMPLICITPOINT", + AssocConstraintNodeData::ImplicitPoint { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: None, + point_type, + point_index: -1, + curve_id, + }, + ); + self.entities.entry(handle).or_default().points.insert(marker, node_id); + Some(node_id) + } + + /// The node id `SketchRef` resolves to: a point node for a marked + /// reference, the whole geometry node otherwise. + fn ref_node(&mut self, r: SketchRef) -> Option { + match r.marker { + Some(marker) => self.point_node(r.entity, marker), + None => self.geometry_node(r.entity), + } + } + + /// Records an undirected edge (design doc's confirmed reading of + /// `AcConstraintGroupNode`: "connection between two nodes is not + /// directed") between two node ids, on both nodes' own `connections`. + fn connect(&mut self, a: i32, b: i32) { + if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) { + node.connections.push(b); + } + if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == b) { + node.connections.push(a); + } + } +} + +/// Class name for a plain `Geometrical`-shaped constraint — the +/// `is_plain_geometrical_constraint` list confirmed in +/// `dwg_stream_readers/object_reader/associative.rs` (design doc §3). +fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { + match kind { + ConstraintKind::Coincident => Some("ACPOINTCOINCIDENCECONSTRAINT"), + ConstraintKind::Horizontal => Some("ACHORIZONTALCONSTRAINT"), + ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), + ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), + ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), + // `Equal` isn't one native kind — the line/circle split below + // (design doc §5) needs the resolved entity types, not just the + // ConstraintKind, so it's handled by the caller instead of here. + ConstraintKind::Equal | ConstraintKind::Parallel | ConstraintKind::Distance + | ConstraintKind::Angle | ConstraintKind::Radius => None, + } +} + +const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { + match kind { + ConstraintKind::Distance => "ACDISTANCECONSTRAINT", + ConstraintKind::Angle => "ACANGLECONSTRAINT", + ConstraintKind::Radius => "ACRADIUSDIAMETERCONSTRAINT", + _ => "", + } +} + +/// Builds one `AssocConstraintNode` for `constraint`, returning its node id +/// so the caller can track which ones still need a `value_dependency` +/// patched in once handles can be allocated (pass 2, `Allocator` — building +/// node *shape* only needs read access to resolve `Equal`'s line-vs-circle +/// split, `AssocGeomDependency`/`AssocValueDependency` handles need mutable +/// access to the same document, so this has to be two passes). `None` if +/// this constraint's refs don't resolve to nodes this pass supports +/// (mirrors `sketch_solve::build_constraint`'s own "skip, don't error" +/// contract for an unbuildable constraint). +fn constraint_node( + builder: &mut GroupBuilder, + document: &CadDocument, + constraint: &SketchConstraint, + needs_value_dependency: &mut Vec<(i32, Option)>, +) -> Option { + let refs: &[SketchRef] = &constraint.refs; + if let Some(class_name) = geometrical_class_name(constraint.kind) { + let target_refs: Vec = refs.iter().filter_map(|r| builder.ref_node(*r)).collect(); + if target_refs.len() != refs.len() { + return None; + } + let owner_id = *target_refs.first()?; + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + class_name, + AssocConstraintNodeData::Geometrical { owner_id, is_implied: false, is_active: true }, + ); + for target in &target_refs { + builder.connect(node_id, *target); + } + return Some(node_id); + } + match constraint.kind { + ConstraintKind::Parallel => { + let [a, b] = refs else { return None }; + let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + "ACPARALLELCONSTRAINT", + AssocConstraintNodeData::Parallel { owner_id: na, is_implied: false, is_active: true, datum_line_index: None }, + ); + builder.connect(node_id, na); + builder.connect(node_id, nb); + Some(node_id) + } + ConstraintKind::Equal => { + let [a, b] = refs else { return None }; + let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); + let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) + && matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); + let class_name = if both_lines { "ACEQUALLENGTHCONSTRAINT" } else { "ACEQUALRADIUSCONSTRAINT" }; + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + class_name, + AssocConstraintNodeData::Geometrical { owner_id: na, is_implied: false, is_active: true }, + ); + builder.connect(node_id, na); + builder.connect(node_id, nb); + Some(node_id) + } + ConstraintKind::Distance | ConstraintKind::Angle | ConstraintKind::Radius => { + let target_refs: Vec = match constraint.kind { + ConstraintKind::Radius => vec![builder.ref_node(*refs.first()?)?], + _ => { + let [a, b] = refs else { return None }; + vec![builder.ref_node(*a)?, builder.ref_node(*b)?] + } + }; + let owner_id = *target_refs.first()?; + let node_id = builder.alloc_node_id(); + let data = match constraint.kind { + ConstraintKind::Distance => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 0, + distance: None, + }, + ConstraintKind::Angle => AssocConstraintNodeData::Angle { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + sector_type: 0, + }, + ConstraintKind::Radius => AssocConstraintNodeData::RadiusDiameter { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + mode: 0, + }, + _ => unreachable!(), + }; + builder.push_node(node_id, dimensional_class_name(constraint.kind), data); + for target in &target_refs { + builder.connect(node_id, *target); + } + needs_value_dependency.push((node_id, constraint.driving_param.clone())); + Some(node_id) + } + _ => None, + } +} + +/// Everything a scope's native graph needs beyond the group's own object: +/// the `AssocGeomDependency` and `AssocValueDependency`/`AssocVariable` +/// objects it references by handle, and the network/dictionary wiring. +struct Allocator<'a> { + document: &'a mut CadDocument, + /// One `AssocVariable` handle per distinct named parameter actually + /// referenced in this scope, created once and shared by every + /// constraint that references the same name (mirrors how AutoCAD's own + /// variables work — one object, many dependents). + variables: FxHashMap, +} + +impl Allocator<'_> { + fn insert_associative(&mut self, owner: Handle, dxf_name: &str, cpp_class_name: &str, data: AssociativeData) -> Handle { + let handle = self.document.allocate_handle(); + self.insert_associative_at(handle, owner, dxf_name, cpp_class_name, data); + handle + } + + /// Like [`Allocator::insert_associative`], but for a handle already + /// allocated up front — needed for the group/network pair, which + /// reference each other (`AssocAction::owning_network` / + /// `AssocNetwork::owned_actions`) and so must both have real handles + /// before either object is actually built. + fn insert_associative_at(&mut self, handle: Handle, owner: Handle, dxf_name: &str, cpp_class_name: &str, data: AssociativeData) { + let object = AssociativeObject { + handle, + owner, + dxf_name: dxf_name.to_string(), + cpp_class_name: cpp_class_name.to_string(), + data, + ..Default::default() + }; + self.document.objects.insert(handle, ObjectType::Associative(object)); + } + + fn geom_dependency(&mut self, group_handle: Handle, entity: Handle) -> Handle { + self.insert_associative( + group_handle, + "ACDBASSOCGEOMDEPENDENCY", + "AcDbAssocGeomDependency", + AssociativeData::GeomDependency(AssocGeomDependency { + dependency: AssocDependency { + class_version: 1, + is_read_dependency: true, + is_write_dependency: true, + is_attached_to_object: true, + dependent_on: entity, + ..Default::default() + }, + class_version: 1, + enabled: true, + // Whole-entity dependency, not a sub-entity — no persistent + // subentity class name needed (design doc §7.2: the real + // point/curve addressing lives on the constraint-node data, + // not here). + persistent_subent: AssocPersistentSubentId::default(), + }), + ) + } + + /// The `AssocVariable` handle for `name`, creating it once per scope. + /// `formula` is our own `ParameterTable` source string for this name — + /// `AcDbAssocVariable.expression` is exactly this shape (design doc §5). + fn variable(&mut self, group_handle: Handle, name: &str, formula: &str) -> Handle { + if let Some(&handle) = self.variables.get(name) { + return handle; + } + let handle = self.insert_associative( + group_handle, + "ACDBASSOCVARIABLE", + "AcDbAssocVariable", + AssociativeData::Variable(AssocVariable { + action: AssocAction::default(), + class_version: 1, + name: name.to_string(), + expression: formula.to_string(), + // Left empty deliberately: "If empty, the default evaluator + // for current acad version will be used" (Autodesk's own + // AcDbAssocVariable.h, design doc §5/§7.4) — not a gap. + evaluator: String::new(), + description: String::new(), + value: AssocEvalVariant::default(), + has_cached_value: false, + cached_value: String::new(), + flag: false, + reserved: 0, + }), + ); + self.variables.insert(name.to_string(), handle); + handle + } + + /// `variable_handle` is `Some` (from a prior call to [`Allocator::variable`]) + /// when this driving value is `DrivingValue::Named` — `Handle::NULL`'s + /// meaning for `AssocDependency::dependent_on` (design doc §5: "`NULL` + /// for a bare literal, or an `AssocVariable`'s handle when the value is + /// driven by a named parameter") for a plain `DrivingValue::Literal`. + fn value_dependency(&mut self, group_handle: Handle, variable_handle: Option, resolved: f64) -> Handle { + let value = AssocEvalVariant { code: 40, value: AssocEvalValue::Real(resolved) }; + self.insert_associative( + group_handle, + "ACDBASSOCVALUEDEPENDENCY", + "AcDbAssocValueDependency", + AssociativeData::ValueDependency(AssocValueDependency { + dependency: AssocDependency { + class_version: 1, + is_read_dependency: true, + is_write_dependency: false, + dependent_on: variable_handle.unwrap_or(Handle::NULL), + ..Default::default() + }, + class_version: 1, + name: String::new(), + value, + }), + ) + } +} + +/// Patches a geometry node's `geometry_dependency` field — only the +/// whole-geometry variants (`BoundedLine`/`Circle`/`Arc`) carry one; +/// `ImplicitPoint` nodes stay `Handle::NULL` (design doc §7.2, confirmed +/// from Autodesk's own `AcConstrainedImplicitPoint` docs: it does not hold +/// its own `AssocGeomDependency`, only `curve_id` pointing at the geometry +/// node that does). +fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { + match data { + AssocConstraintNodeData::BoundedLine { geometry_dependency, .. } + | AssocConstraintNodeData::Circle { geometry_dependency, .. } + | AssocConstraintNodeData::Arc { geometry_dependency, .. } => { + *geometry_dependency = handle; + } + _ => {} + } +} + +/// Patches a dimensional-constraint node's `value_dependency` field +/// (`Distance`/`Angle`/`RadiusDiameter` — the three kinds +/// [`constraint_node`] defers to `needs_value_dependency`). +fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { + match data { + AssocConstraintNodeData::Distance { value_dependency, .. } + | AssocConstraintNodeData::Angle { value_dependency, .. } + | AssocConstraintNodeData::RadiusDiameter { value_dependency, .. } => { + *value_dependency = handle; + } + _ => {} + } +} + +/// The DWG/DXF CLASSES-table entries this module's five `AssociativeObject` +/// kinds need before they can be written with a real class number. +/// Confirmed by reading the actual writer: `write_associative_object` +/// resolves the class number via +/// `document.classes.get_by_name(&object.dxf_name)` +/// (`io/dwg/dwg_stream_writers/object_writer/associative.rs:700-709`), and +/// falls back to a generic type code 500 for every unregistered class alike +/// if it misses — which also means the CLASSES section written to the file +/// never declares them, so a reload can't dispatch the type-500 objects back +/// to `read_associative_object` either (`dwg_document_builder.rs`'s object +/// dispatcher keys off exactly that class-number -> name mapping). A fresh +/// `CadDocument::initialize_defaults` only registers classes every drawing +/// needs, not ones specific to this feature, so this module registers its +/// own — the same pattern `CadDocument::register_object_context_class` +/// already uses for a different object family. +const ASSOC_CLASSES: &[(&str, &str)] = &[ + ("ASSOC2DCONSTRAINTGROUP", "AcDbAssoc2dConstraintGroup"), + ("ASSOCNETWORK", "AcDbAssocNetwork"), + ("ASSOCVARIABLE", "AcDbAssocVariable"), + ("ASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency"), + ("ASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency"), +]; + +fn ensure_associative_classes_registered(document: &mut CadDocument) { + for (dxf_name, cpp_class_name) in ASSOC_CLASSES { + if document.classes.get_by_name(dxf_name).is_some() { + continue; + } + document.classes.add_or_update(acadrust::classes::DxfClass { + dxf_name: dxf_name.to_string(), + cpp_class_name: cpp_class_name.to_string(), + application_name: "ObjectDBX Classes".to_string(), + proxy_flags: acadrust::classes::ProxyFlags( + acadrust::classes::ProxyFlags::ERASE_ALLOWED.0 + | acadrust::classes::ProxyFlags::CLONING_ALLOWED.0 + | acadrust::classes::ProxyFlags::DISABLES_PROXY_WARNING_DIALOG.0, + ), + instance_count: 0, + was_zombie: false, + is_an_entity: false, + class_number: 0, + // 499 = "object" (non-entity) — every class here is one. + item_class_id: 0x1F3, + dwg_version: 0, + maintenance_version: 0, + unknown1: 0, + unknown2: 0, + }); + } +} + +/// Ensures `owner`'s extension dictionary exists, returning its handle. +/// `acadrust::CadDocument` only exposes creating one through +/// [`CadDocument::ensure_xrecord`] — the `xdic_by_handle` side map a +/// Dictionary-owned object would otherwise need is private to that crate +/// (design doc's documented reason this module anchors at a real entity +/// owner instead of the top-level NOD). So a throwaway XRecord is created +/// and immediately removed purely to force that wiring through the one +/// public entry point that gets it right for both DWG and DXF. +fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle { + if let Some(handle) = document.extension_dictionary_handle(owner) { + return handle; + } + const BOOTSTRAP_KEY: &str = "OCS_DWG_NATIVE_BOOTSTRAP"; + document.ensure_xrecord(owner, BOOTSTRAP_KEY); + let dictionary_handle = document + .extension_dictionary_handle(owner) + .expect("ensure_xrecord just created the extension dictionary"); + remove_dictionary_entry(document, dictionary_handle, BOOTSTRAP_KEY); + dictionary_handle +} + +/// Removes a named entry from `dictionary_handle`, and recursively removes +/// the object it pointed to — used both for the bootstrap XRecord above and +/// for clearing a stale `ACAD_ASSOCNETWORK` entry before a resave rebuilds +/// it from scratch. +fn remove_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str) { + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) else { + return; + }; + let Some(index) = dictionary.entries.iter().position(|(name, _)| name.eq_ignore_ascii_case(key)) else { + return; + }; + let (_, target) = dictionary.entries.remove(index); + remove_owned_recursive(document, target); +} + +fn set_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str, target: Handle) { + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) else { + return; + }; + if let Some((_, handle)) = dictionary.entries.iter_mut().find(|(name, _)| name.eq_ignore_ascii_case(key)) { + *handle = target; + } else { + dictionary.add_entry(key, target); + } +} + +/// Removes `root` and every object transitively owned by it (design doc §6: +/// this module rebuilds a scope's whole native graph on every save, so the +/// previous save's objects must be fully torn down first — otherwise a +/// resave accumulates orphaned `AssocGeomDependency`/`AssocValueDependency`/ +/// `AssocVariable` objects forever instead of replacing them). +fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { + if root.is_null() { + return; + } + let children: Vec = + document.objects.keys().copied().filter(|&h| document.object_owner(h) == Some(root)).collect(); + for child in children { + remove_owned_recursive(document, child); + } + document.objects.remove(&root); +} + +/// One scope's worth of +/// [`Scene::materialize_dwg_native_constraints_for_save`] — see that +/// method's doc comment for the save-time contract this implements. +fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchConstraintSet, parameters: &ParameterTable) { + let dictionary_handle = ensure_extension_dictionary(document, owner); + remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY); + + // Pass 1 (read-only): constraint-node shapes. + let mut needs_value_dependency = Vec::new(); + let (mut nodes, entities) = { + let mut builder = GroupBuilder::new(document); + for constraint in &set.constraints { + if !constraint.enabled { + continue; + } + constraint_node(&mut builder, document, constraint, &mut needs_value_dependency); + } + let GroupBuilder { nodes, entities, .. } = builder; + (nodes, entities) + }; + if nodes.is_empty() { + return; + } + + // Root pseudo-node (`nodes[0]`): confirmed mandatory by reading the DWG + // writer — `Assoc2dConstraintGroup`'s write path only emits node data at + // all when `nodes.first()` is `Some`, so an empty `Vec` would skip node + // serialization entirely. This synthetic anchor is what makes every + // other node reach the file, not decoration. + let all_ids: Vec = nodes.iter().map(|n| n.node_id).collect(); + nodes.insert( + 0, + AssocConstraintNode { + node_id: 0, + status: 1, + connections: all_ids, + class_name: String::new(), + registry_flag: false, + data: AssocConstraintNodeData::None, + }, + ); + + // Pass 2 (mutable): allocate handles for everything the node shapes + // above still reference as `Handle::NULL`. The group and network + // reference each other, so both handles are reserved up front. + let group_handle = document.allocate_handle(); + let network_handle = document.allocate_handle(); + let mut allocator = Allocator { document, variables: FxHashMap::default() }; + + for (entity_handle, entity_nodes) in &entities { + if entity_nodes.geometry_node_id == 0 { + continue; + } + let dep_handle = allocator.geom_dependency(group_handle, *entity_handle); + if let Some(node) = nodes.iter_mut().find(|n| n.node_id == entity_nodes.geometry_node_id) { + set_geometry_dependency(&mut node.data, dep_handle); + } + } + + for (node_id, driving) in needs_value_dependency { + let Some(driving) = driving else { continue }; + let Ok(resolved) = driving.resolve(parameters) else { continue }; + let variable_handle = match &driving { + DrivingValue::Named(name) => { + let formula = parameters.get(name).map(|p| p.source.clone()).unwrap_or_default(); + Some(allocator.variable(group_handle, name, &formula)) + } + DrivingValue::Literal(_) => None, + }; + let dep_handle = allocator.value_dependency(group_handle, variable_handle, resolved); + if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { + set_value_dependency(&mut node.data, dep_handle); + } + } + + let group = Assoc2dConstraintGroup { + action: AssocAction { owning_network: network_handle, ..Default::default() }, + version: 1, + flag: true, + // Identity XY plane at the world origin: every entity this module + // builds constraint nodes for already lives in world XY (design doc + // §4 — a rotated/offset sketch plane isn't modeled yet). + work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], + dependency: Handle::NULL, + actions: Vec::new(), + nodes, + }; + allocator.insert_associative_at( + group_handle, + network_handle, + "ASSOC2DCONSTRAINTGROUP", + "AcDbAssoc2dConstraintGroup", + AssociativeData::ConstraintGroup(group), + ); + + let network = AssocNetwork { + action: AssocAction::default(), + network_version: 1, + network_action_index: 0, + actions: Vec::new(), + owned_actions: vec![group_handle], + }; + allocator.insert_associative_at( + network_handle, + dictionary_handle, + "ASSOCNETWORK", + "AcDbAssocNetwork", + AssociativeData::Network(network), + ); + + set_dictionary_entry(allocator.document, dictionary_handle, NETWORK_DICTIONARY_KEY, network_handle); +} + +impl Scene { + /// Rebuilds every sketch scope's native `AssocNetwork`/ + /// `Assoc2dConstraintGroup` graph from `self.sketch_constraints`, + /// replacing whatever this module wrote on a previous save — the same + /// "materialize right before save, from scratch every time" convention + /// `sketch_persist.rs`'s `materialize_sketch_constraints_for_save` uses, + /// called alongside it, not instead of it (design doc §6: additive). + pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self) { + ensure_associative_classes_registered(&mut self.document); + for index in 0..self.sketch_constraints.len() { + let set = self.sketch_constraints[index].clone(); + let owner = set.scope.owner_handle(&self.document); + if owner.is_null() { + continue; + } + materialize_scope(&mut self.document, owner, &set, &self.named_parameters); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::sketch_constraints::SketchScope; + use super::*; + use acadrust::entities::Line; + + fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(start.0, start.1, 0.0), + Vector3::new(end.0, end.1, 0.0), + ))) + } + + /// Digs a scope's materialized native graph back out of `document`: + /// `owner`'s extension dictionary -> `ACAD_ASSOCNETWORK` entry -> + /// its one owned action, which (for this module) is always the + /// `Assoc2dConstraintGroup` itself. Panics with a descriptive message on + /// any broken link, since every test using this expects the graph to be + /// present and well-formed. + fn native_group(document: &CadDocument, owner: Handle) -> &Assoc2dConstraintGroup { + let dict = document.extension_dictionary_handle(owner).expect("extension dictionary should exist"); + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dict) else { + panic!("expected owner's extension dictionary object to exist"); + }; + let network_handle = + dictionary.get(NETWORK_DICTIONARY_KEY).expect("ACAD_ASSOCNETWORK entry should exist"); + let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::Network(network), .. })) = + document.objects.get(&network_handle) + else { + panic!("expected an AssocNetwork object at the ACAD_ASSOCNETWORK handle"); + }; + let group_handle = *network.owned_actions.first().expect("network should own the constraint group"); + let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(group), .. })) = + document.objects.get(&group_handle) + else { + panic!("expected an Assoc2dConstraintGroup object at the network's owned action handle"); + }; + group + } + + #[test] + fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Vertical, + vec![SketchRef::whole(b)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(); + let owner = scene.document.header.model_space_block_handle; + // Sanity before the round trip even happens: root + 2 geometry + // nodes + 2 constraint nodes. + let before = native_group(&scene.document, owner); + assert_eq!(before.nodes.len(), 5, "expected root + 2 geometry + 2 constraint nodes, got {:#?}", before.nodes); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("dwg_native_poc.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACHORIZONTALCONSTRAINT"), + "{ext}: missing horizontal constraint node, got {class_names:?}" + ); + assert!( + class_names.contains(&"ACVERTICALCONSTRAINT"), + "{ext}: missing vertical constraint node, got {class_names:?}" + ); + assert_eq!( + class_names.iter().filter(|&&n| n == "ACCONSTRAINEDBOUNDEDLINE").count(), + 2, + "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" + ); + } + } + + fn distance_with_named_parameter_scene() -> (Scene, Handle) { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let b = line_entity(&mut scene, (20.0, 0.0), (30.0, 0.0)); + scene.named_parameters.set("gap", "5").expect("defining the parameter should succeed"); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + Some(DrivingValue::Named("gap".to_string())), + ); + let owner = scene.document.header.model_space_block_handle; + (scene, owner) + } + + fn has_variable_named(document: &CadDocument, name: &str) -> bool { + document.objects.values().any(|obj| { + matches!( + obj, + ObjectType::Associative(AssociativeObject { data: AssociativeData::Variable(v), .. }) + if v.name == name + ) + }) + } + + /// DWG is the format this module's dependency chain (`AssocGeomDependency` + /// / `AssocValueDependency` / `AssocVariable`) actually survives a save + /// through — see [`a_dxf_save_keeps_only_the_constraint_group_shell`] for + /// the DXF side of this same scene and why it's different, not a bug here. + #[test] + fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { + let (mut scene, owner) = distance_with_named_parameter_scene(); + scene.materialize_dwg_native_constraints_for_save(); + assert!(has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization"); + + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .unwrap_or_else(|e| panic!("save to dwg: {e}")); + let reloaded = crate::io::load_bytes("dwg_native_named_param.dwg", bytes) + .unwrap_or_else(|e| panic!("reload dwg: {e}")); + + let group = native_group(&reloaded, owner); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACDISTANCECONSTRAINT"), + "expected an ACDISTANCECONSTRAINT node, got {:?}", + group.nodes.iter().map(|n| &n.class_name).collect::>() + ); + assert!(has_variable_named(&reloaded, "gap"), "expected the AssocVariable \"gap\" to survive a DWG round trip"); + } + + /// Confirmed by reading the actual DXF writer + /// (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`, + /// called from its `ObjectType::Associative` write arm): `acadrust`'s DXF + /// path deliberately does not write `AssocDependency`/ + /// `AssocValueDependency`/`AssocGeomDependency`/`AssocVariable` objects at + /// all ("Unrestorable associative-framework objects are not written") — + /// DWG has no equivalent filter. So a DXF save keeps the + /// `Assoc2dConstraintGroup`/`AssocNetwork` shell (and every constraint + /// node's own embedded geometry — real coordinates, not lost), but each + /// node's `geometry_dependency`/`value_dependency` handle points at + /// nothing once reloaded, and no `AssocVariable` survives at all. This is + /// a real, upstream `acadrust` limitation, not something this module can + /// work around without patching that dependency — out of scope here (see + /// this project's stated preference, `named_parameters_persist.rs`, to + /// pick a different approach rather than patch `acadrust`). DWG is + /// therefore the only format this feature round-trips fully through. + #[test] + fn a_dxf_save_keeps_only_the_constraint_group_shell() { + let (mut scene, owner) = distance_with_named_parameter_scene(); + scene.materialize_dwg_native_constraints_for_save(); + + let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version) + .unwrap_or_else(|e| panic!("save to dxf: {e}")); + let reloaded = crate::io::load_bytes("dwg_native_named_param.dxf", bytes) + .unwrap_or_else(|e| panic!("reload dxf: {e}")); + + let group = native_group(&reloaded, owner); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACDISTANCECONSTRAINT"), + "expected the ACDISTANCECONSTRAINT node's shell to survive, got {:?}", + group.nodes.iter().map(|n| &n.class_name).collect::>() + ); + assert!( + !has_variable_named(&reloaded, "gap"), + "acadrust's DXF writer should not carry AssocVariable — if this now passes, upstream has fixed \ + is_unrestorable_assoc_object and materialize_scope's doc comments/design doc should be updated" + ); + } + + #[test] + fn resaving_replaces_rather_than_accumulates_native_objects() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(); + let first_count = scene.document.objects.len(); + scene.materialize_dwg_native_constraints_for_save(); + let second_count = scene.document.objects.len(); + assert_eq!(first_count, second_count, "a resave with the same constraints must not accumulate new objects"); + } + + #[test] + fn an_empty_constraint_set_leaves_no_native_network() { + let mut scene = Scene::new(); + let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + scene.materialize_dwg_native_constraints_for_save(); + + let owner = scene.document.header.model_space_block_handle; + let dict = scene.document.extension_dictionary_handle(owner); + if let Some(dict) = dict { + if let Some(ObjectType::Dictionary(dictionary)) = scene.document.objects.get(&dict) { + assert!( + dictionary.get(NETWORK_DICTIONARY_KEY).is_none(), + "an unconstrained scope should not materialize a native network" + ); + } + } + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 8fbbfac1e..cba0c7885 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,6 +31,7 @@ mod camera_ops; pub(crate) mod centerline; pub(crate) mod dimension_assoc; pub(crate) mod centermark; +mod dwg_native_constraints; mod entity; mod group_layer; mod layout; From 31b5ef4ec3072e9e08c4a9de529461918c76d5fb Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Wed, 9 Sep 2026 19:38:31 +0200 Subject: [PATCH 07/99] Add 8 geometric constraint kinds and an opt-in DWG-native persistence toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ConstraintKind variants — Concentric, CenterPoint, Colinear, Midpoint, Fixed, PointOnCurve, EqualDistance, Symmetric — each built entirely from existing ocs_gcs (FreeCAD planegcs) primitives, no new solver math needed. build_constraint now returns Vec> instead of Option>, generalizing the old Coincident-only "needs a second system-level constraint" special case to every kind that needs one. Wired into the ribbon (7 new buttons), a new point+pre-selected-entity command for CenterPoint/Midpoint/PointOnCurve, a new 4-point command for EqualDistance, and the existing DWG-native class-name mapping (all 8 reuse the same generic "plain Geometrical node" path already built for Coincident/Horizontal/etc.). Deliberately not modeled: Normal and EqualCurvature, which for this app's Line/Circle-only entity model would just be Perpendicular and Equal wearing different DWG class names. Also makes DWG-native constraint persistence opt-in (Options > General, off by default) rather than unconditional: a scope that doesn't yet have the native graph is only given one when the setting is on, but a scope that already has one — from an earlier save, or a file that came from real AutoCAD — keeps getting it resynced regardless of the setting, so toggling it off never freezes a stale graph in place. Co-Authored-By: Claude Sonnet 5 --- src/app/command_driver.rs | 56 +++++ src/app/commands/draw.rs | 91 ++++++- src/app/mod.rs | 17 ++ src/app/settings.rs | 10 + src/app/update/file.rs | 6 +- src/app/update/mod.rs | 5 + src/app/view/modal.rs | 1 + src/command.rs | 26 ++ src/modules/draw/constrain/equal_distance.rs | 67 +++++ src/modules/draw/constrain/mod.rs | 37 ++- src/modules/draw/constrain/point_on_entity.rs | 91 +++++++ src/modules/draw/constrain/tools.rs | 48 ++++ src/modules/draw/mod.rs | 14 +- src/scene/dwg_native_constraints.rs | 205 ++++++++++++++- src/scene/sketch_constraints.rs | 43 ++++ src/scene/sketch_solve.rs | 237 ++++++++++++------ src/ui/window/options.rs | 19 ++ tests/sketch_constraints_solve.rs | 209 +++++++++++++++ 18 files changed, 1076 insertions(+), 106 deletions(-) create mode 100644 src/modules/draw/constrain/equal_distance.rs create mode 100644 src/modules/draw/constrain/point_on_entity.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a455b7a0f..28280035d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2375,6 +2375,62 @@ impl OpenCADStudio { } } } + CmdResult::AddPointOnEntityConstraint { point, target, kind, label } => { + let scope = self.tabs[i].current_sketch_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let resolved = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target)); + match resolved { + Some(point_ref) => { + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + // `CenterPoint` addresses its whole-entity side via + // the center marker (same solve as Coincident / + // Concentric — `ConstraintKind::CenterPoint`'s own + // doc comment); `Midpoint`/`PointOnCurve` address it + // as a whole entity. + let target_ref = + if kind == ConstraintKind::CenterPoint { SketchRef::center(target) } else { SketchRef::whole(target) }; + return self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind, + refs: vec![point_ref, target_ref], + driving_param: None, + label, + }); + } + None => { + self.command_line.push_error( + "Pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } + } + } + CmdResult::AddEqualDistanceConstraint { points, label } => { + let scope = self.tabs[i].current_sketch_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let mut refs = Vec::with_capacity(4); + for p in points { + let Some(r) = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(p), None) + else { + refs.clear(); + break; + }; + refs.push(r); + } + if refs.len() == 4 { + return self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: crate::scene::sketch_constraints::ConstraintKind::EqualDistance, + refs, + driving_param: None, + label, + }); + } + self.command_line.push_error( + "Equal Distance: a pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } CmdResult::ReassociateCenterMark { target, source, point } => { if self.reject_locked_edit(i, target) { return Task::none(); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 42afe995a..9e86e2434 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -959,7 +959,7 @@ impl OpenCADStudio { // ── Constraints (persistent: added to the scope's SketchConstraintSet, // solved via Scene::bump_entities, re-solved on every later edit — // see docs/parametric_system_design.md §6.1) ── - "HCONSTRAINT" | "VCONSTRAINT" => { + "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -968,14 +968,14 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(sel)); } else if handles.len() != 1 { self.command_line - .push_output("Select exactly one line, then run this constraint again."); + .push_output("Select exactly one entity, then run this constraint again."); } else { use crate::command::CmdResult; use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; - let (kind, label) = if cmd == "HCONSTRAINT" { - (ConstraintKind::Horizontal, "Horizontal constraint") - } else { - (ConstraintKind::Vertical, "Vertical constraint") + let (kind, label) = match cmd { + "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), + "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), + _ => (ConstraintKind::Fixed, "Fixed constraint"), }; return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { kind, @@ -993,7 +993,14 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" => { + "EDCONSTRAINT" => { + use crate::modules::draw::constrain::EqualDistanceConstraintCommand; + let new_cmd = EqualDistanceConstraintCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1011,6 +1018,7 @@ impl OpenCADStudio { "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), "QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"), "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), + "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), _ => (ConstraintKind::Equal, "Equal constraint"), }; return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { @@ -1022,6 +1030,75 @@ impl OpenCADStudio { } } + "NCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 2 { + self.command_line.push_output("Select exactly two circles/arcs, then run this constraint again."); + } else { + use crate::command::CmdResult; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Concentric, + refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])], + driving_param: None, + label: "Concentric constraint", + })); + } + } + + "SYCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 3 { + self.command_line.push_output( + "Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.", + ); + } else { + use crate::command::CmdResult; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Symmetric, + refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])], + driving_param: None, + label: "Symmetric constraint", + })); + } + } + + "CPCONSTRAINT" | "MPCONSTRAINT" | "OCCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 1 { + self.command_line.push_output( + "Select exactly one entity (the circle or line the point should attach to), then run this constraint again.", + ); + } else { + use crate::modules::draw::constrain::PointOnEntityConstraintCommand; + use crate::scene::sketch_constraints::ConstraintKind; + let (name, kind, label) = match cmd { + "CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"), + "MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"), + _ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"), + }; + let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + } + "DCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { diff --git a/src/app/mod.rs b/src/app/mod.rs index b7723545f..90dd635d5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -533,6 +533,19 @@ pub(super) struct OpenCADStudio { /// When true (default), the app registers itself as a .dwg/.dxf/.bak file /// handler on each launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, + /// When true, saving a drawing with sketch constraints also materializes + /// them as AutoCAD's own native `AssocNetwork`/`Assoc2dConstraintGroup` + /// object graph (`scene::dwg_native_constraints`), so AutoCAD/BricsCAD + /// recognize them as real constraints rather than an opaque OCS-only + /// XRecord. Off by default (#opt-in — adds objects/file weight every + /// user pays for save-after-save, whether or not they need AutoCAD + /// interop). A drawing that already carries this native graph (from a + /// prior save with this on, or from real AutoCAD) keeps it synced on + /// every save regardless of this setting — see + /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment; + /// this only governs whether the graph is *created* in a scope that + /// doesn't have one yet. + pub write_dwg_native_constraints: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -2002,6 +2015,9 @@ pub enum Message { /// Register or unregister as the .dwg/.dxf handler, from Options. Same /// setting the FILEASSOC command carries. FileAssocChanged(bool), + /// Toggle writing AutoCAD-native constraint objects on save, from + /// Options. See `write_dwg_native_constraints`'s doc comment. + WriteDwgNativeConstraintsChanged(bool), /// Switch the interface language and redraw localized views. LanguageChanged(crate::i18n::Language), /// Drop every entity from the active drawing. @@ -3426,6 +3442,7 @@ impl OpenCADStudio { dimension_continue_mode: 1, backup_on_save: true, file_assoc_enabled: true, + write_dwg_native_constraints: false, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, diff --git a/src/app/settings.rs b/src/app/settings.rs index 589555127..b342766f9 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -225,6 +225,15 @@ pub struct UserSettings { /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// handler on every launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, + /// When true, saving a drawing also writes its sketch constraints as + /// AutoCAD's own native object graph (`scene::dwg_native_constraints`), + /// not just the app's own XRecord format. Off by default — opt-in, since + /// it adds file weight every save whether or not AutoCAD/BricsCAD + /// interop is needed. See `write_dwg_native_constraints` on the app + /// struct for the full contract (a drawing that already has this native + /// graph keeps it synced regardless of this setting). + #[serde(default)] + pub write_dwg_native_constraints: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -338,6 +347,7 @@ impl Default for UserSettings { textfill: true, backup_on_save: true, file_assoc_enabled: true, + write_dwg_native_constraints: false, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1b2cc5063..a4f61f3d2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -393,6 +393,7 @@ impl OpenCADStudio { textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, + write_dwg_native_constraints: self.write_dwg_native_constraints, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -459,6 +460,7 @@ impl OpenCADStudio { crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; + self.write_dwg_native_constraints = s.write_dwg_native_constraints; self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); @@ -1671,7 +1673,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // docs/dwg_constraint_compatibility_design.md: additive DWG/DXF- // native constraint graph, alongside (not instead of) the XRecord // above — same save-time hook. - self.tabs[i].scene.materialize_dwg_native_constraints_for_save(); + self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); } #[cfg(not(target_arch = "wasm32"))] @@ -2624,7 +2626,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.sync_solid_models_for_save(i); self.tabs[i].scene.materialize_sketch_constraints_for_save(); self.tabs[i].scene.materialize_named_parameters_for_save(); - self.tabs[i].scene.materialize_dwg_native_constraints_for_save(); + self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); let tab_id = self.tabs[i].id; let bounds = crate::ui::wrap_bar::dropdown_bounds( crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 76cca25dc..5ea85a579 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6198,6 +6198,11 @@ impl OpenCADStudio { } Task::none() } + Message::WriteDwgNativeConstraintsChanged(enabled) => { + self.write_dwg_native_constraints = enabled; + self.persist_settings_if_changed(); + Task::none() + } Message::LanguageChanged(language) => { if self.language == language { return Task::none(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0d921a8c7..42f399036 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -195,6 +195,7 @@ impl OpenCADStudio { crate::ui::window::options::view_window( &self.default_save_format, self.file_assoc_enabled, + self.write_dwg_native_constraints, &self.ui_theme, &self.theme_color_inputs, self.language, diff --git a/src/command.rs b/src/command.rs index 13a109e59..b40bbcc68 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1360,6 +1360,32 @@ pub enum CmdResult { point_b: DVec3, label: &'static str, }, + /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint + /// (`crate::modules::draw::constrain::point_on_entity`) between one + /// picked point and `target`, a whole entity selected before the tool + /// ran. Like `AddCoincidentConstraint`, the host resolves `point` via + /// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no + /// document access) and, for `CenterPoint` specifically, addresses + /// `target` via its center marker rather than as a whole entity — see + /// `ConstraintKind::CenterPoint`'s own doc comment for why it's the + /// same solve as `Coincident`/`Concentric` under a different DWG-native + /// class name. + AddPointOnEntityConstraint { + point: DVec3, + target: Handle, + kind: crate::scene::sketch_constraints::ConstraintKind, + label: &'static str, + }, + /// Add a persistent `EqualDistance` constraint + /// (`crate::modules::draw::constrain::equal_distance`): the distance + /// between `points[0]`/`points[1]` equals the distance between + /// `points[2]`/`points[3]`. The host resolves each point via + /// `sketch_constraints::nearest_sketch_point`, same reasoning as + /// `AddCoincidentConstraint`. + AddEqualDistanceConstraint { + points: [DVec3; 4], + label: &'static str, + }, /// Attach one smart centre mark to a newly selected circular source. ReassociateCenterMark { target: Handle, diff --git a/src/modules/draw/constrain/equal_distance.rs b/src/modules/draw/constrain/equal_distance.rs new file mode 100644 index 000000000..ed648a458 --- /dev/null +++ b/src/modules/draw/constrain/equal_distance.rs @@ -0,0 +1,67 @@ +//! EDCONSTRAINT — AutoCAD's fourth `Equal` sub-kind +//! (`ACEQUALDISTANCECONSTRAINT`): the distance between one point pair +//! equals the distance between another, rather than two whole entities +//! being equal length/radius. Needs four point picks (unlike the two +//! whole-entity selections `ECONSTRAINT` uses), so — like +//! `CoincidentConstraintCommand` — it accumulates raw points and hands them +//! back for the host to resolve (`CadCommand` has no document access). + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub mod equal_distance_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "EDCONSTRAINT", + label: "Equal Distance", + icon: IconKind::Glyph("≐"), + event: ModuleEvent::Command("EDCONSTRAINT".to_string()), + } + } +} + +#[derive(Default)] +pub struct EqualDistanceConstraintCommand { + points: Vec, +} + +impl EqualDistanceConstraintCommand { + pub fn new() -> Self { + Self::default() + } +} + +impl CadCommand for EqualDistanceConstraintCommand { + fn name(&self) -> &'static str { + "EDCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => "EQUAL DISTANCE Specify first point of first pair:".to_string(), + 1 => "EQUAL DISTANCE Specify second point of first pair:".to_string(), + 2 => "EQUAL DISTANCE Specify first point of second pair:".to_string(), + _ => "EQUAL DISTANCE Specify second point of second pair:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.points.push(pt); + if self.points.len() < 4 { + return CmdResult::NeedPoint; + } + let [a, b, c, d] = self.points[..] else { unreachable!("just checked len == 4") }; + CmdResult::AddEqualDistanceConstraint { points: [a, b, c, d], label: "Equal distance constraint" } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/draw/constrain/mod.rs b/src/modules/draw/constrain/mod.rs index 8cbb9ebc9..652166409 100644 --- a/src/modules/draw/constrain/mod.rs +++ b/src/modules/draw/constrain/mod.rs @@ -1,21 +1,36 @@ //! Ribbon tools and typed-value commands for the persistent "Constraints" //! group (design doc `docs/parametric_system_design.md` §6.1). //! -//! All eight — Horizontal, Vertical, Parallel, Perpendicular, Equal, -//! Tangent, Distance, Angle — add a [`crate::scene::sketch_constraints::SketchConstraint`] -//! to the current scope and let `Scene::bump_entities` solve it, the same +//! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`] +//! to the current scope and lets `Scene::bump_entities` solve it, the same //! path any later edit to the constrained entities re-solves through -//! (`src/scene/sketch_solve.rs`). Horizontal/Vertical/Parallel/Perpendicular/ -//! Equal/Tangent dispatch straight to `CmdResult::AddSketchConstraint` from -//! `src/app/commands/draw.rs` (no typed value needed, so no `CadCommand` of -//! their own); Distance and Angle need one (`value.rs`), since the target -//! value comes from the command line after the tool is clicked; Coincident -//! needs one too (`coincident.rs`), since it addresses a *point* on an -//! entity rather than the whole thing, so it can't reuse plain selection. +//! (`src/scene/sketch_solve.rs`). By how they gather input: +//! - **Plain selection, no picking** — Horizontal, Vertical, Fixed (select +//! one entity); Parallel, Perpendicular, Equal, Tangent, Colinear, +//! Concentric (select two); Symmetric (select three: the mirrored pair, +//! then the mirror line) — dispatch straight to +//! `CmdResult::AddSketchConstraint` from `src/app/commands/draw.rs`. +//! - **A typed value** — Distance, Angle (`value.rs`): the target value +//! comes from the command line after the tool is clicked. +//! - **A picked point, entity pre-selected** — CenterPoint, Midpoint, +//! PointOnCurve (`point_on_entity.rs`): the entity side has no ambiguity +//! (select it first, like Distance/Angle's target), but the point side +//! does (which of a line's two endpoints?), so it's picked interactively +//! and resolved by the host. +//! - **Picked points only, nothing pre-selected** — Coincident +//! (`coincident.rs`, two points), EqualDistance (`equal_distance.rs`, +//! four points): every side is a point, so plain selection can't express +//! it at all. mod coincident; +mod equal_distance; +mod point_on_entity; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; -pub use tools::{equal, horizontal, parallel, perpendicular, tangent, vertical}; +pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use point_on_entity::{center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand}; +pub use tools::{ + concentric, equal, fixed, horizontal, colinear, parallel, perpendicular, symmetric, tangent, vertical, +}; pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand}; diff --git a/src/modules/draw/constrain/point_on_entity.rs b/src/modules/draw/constrain/point_on_entity.rs new file mode 100644 index 000000000..88b5e141a --- /dev/null +++ b/src/modules/draw/constrain/point_on_entity.rs @@ -0,0 +1,91 @@ +//! CENTERPOINT / MIDPOINT / ONCURVE — constraints between one picked point +//! and a whole entity selected *before* the tool runs (same "select first, +//! then invoke" convention `DistanceConstraintCommand` +//! (`src/modules/draw/constrain/value.rs`) already uses for its own target +//! entity). The entity side has no ambiguity (a whole circle, or a whole +//! line whose midpoint/curve is being addressed, not one specific +//! endpoint) so it doesn't need interactive picking; the point side does +//! (which of a line's two endpoints, or some other addressable point), so +//! this command accumulates just that one point pick and hands it back for +//! the host to resolve via `nearest_sketch_point` — the same split +//! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand` +//! has no document access). + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::sketch_constraints::ConstraintKind; +use acadrust::types::Handle; + +pub mod center_point_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "CPCONSTRAINT", + label: "Center Point", + icon: IconKind::Glyph("⊕"), + event: ModuleEvent::Command("CPCONSTRAINT".to_string()), + } + } +} + +pub mod midpoint_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "MPCONSTRAINT", + label: "Midpoint", + icon: IconKind::Glyph("M"), + event: ModuleEvent::Command("MPCONSTRAINT".to_string()), + } + } +} + +pub mod point_on_curve_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "OCCONSTRAINT", + label: "Point on Curve", + icon: IconKind::Glyph("∈"), + event: ModuleEvent::Command("OCCONSTRAINT".to_string()), + } + } +} + +/// Shared by all three tools above — only `name`/`kind`/`label` differ. +pub struct PointOnEntityConstraintCommand { + name: &'static str, + kind: ConstraintKind, + target: Handle, + label: &'static str, +} + +impl PointOnEntityConstraintCommand { + pub fn new(name: &'static str, kind: ConstraintKind, target: Handle, label: &'static str) -> Self { + Self { name, kind, target, label } + } +} + +impl CadCommand for PointOnEntityConstraintCommand { + fn name(&self) -> &'static str { + self.name + } + + fn prompt(&self) -> String { + "Specify point:".to_string() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + CmdResult::AddPointOnEntityConstraint { point: pt, target: self.target, kind: self.kind, label: self.label } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index 9718fa541..bd3e8faff 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -73,3 +73,51 @@ pub mod tangent { } } } + +pub mod concentric { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "NCONSTRAINT", + label: "Concentric", + icon: IconKind::Glyph("◎"), + event: ModuleEvent::Command("NCONSTRAINT".to_string()), + } + } +} + +pub mod colinear { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "LCONSTRAINT", + label: "Colinear", + icon: IconKind::Glyph("L"), + event: ModuleEvent::Command("LCONSTRAINT".to_string()), + } + } +} + +pub mod fixed { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "FXCONSTRAINT", + label: "Fixed", + icon: IconKind::Glyph("F"), + event: ModuleEvent::Command("FXCONSTRAINT".to_string()), + } + } +} + +pub mod symmetric { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "SYCONSTRAINT", + label: "Symmetric", + icon: IconKind::Glyph("S"), + event: ModuleEvent::Command("SYCONSTRAINT".to_string()), + } + } +} diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index d91c83eb9..82882fb99 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -34,7 +34,11 @@ impl CadModule for DrawModule { }; use crate::modules::insert::{create_block, insert_block}; use clipboard::{copy_clip, cut, paste}; - use constrain::{angle_tool, coincident_tool, distance_tool, equal, horizontal, parallel, perpendicular, tangent, vertical}; + use constrain::{ + angle_tool, center_point_tool, coincident_tool, colinear, concentric, distance_tool, equal, + equal_distance_tool, fixed, horizontal, midpoint_tool, parallel, perpendicular, point_on_curve_tool, + symmetric, tangent, vertical, + }; use draw::{arc, circle, ellipse, hatch, line, polyline, shapes}; use groups::{group, ungroup}; use layers::{ @@ -134,6 +138,14 @@ impl CadModule for DrawModule { coincident_tool::tool().into(), distance_tool::tool().into(), angle_tool::tool().into(), + concentric::tool().into(), + colinear::tool().into(), + fixed::tool().into(), + symmetric::tool().into(), + center_point_tool::tool().into(), + midpoint_tool::tool().into(), + point_on_curve_tool::tool().into(), + equal_distance_tool::tool().into(), ], }, RibbonGroup { diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 3b97b4e89..dd29b8322 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -269,6 +269,18 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), + ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"), + ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"), + ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"), + ConstraintKind::Fixed => Some("ACFIXEDCONSTRAINT"), + ConstraintKind::Midpoint => Some("ACMIDPOINTCONSTRAINT"), + ConstraintKind::PointOnCurve => Some("ACPOINTCURVECONSTRAINT"), + ConstraintKind::Symmetric => Some("ACSYMMETRICCONSTRAINT"), + // Also a plain `Geometrical`-shaped node in AutoCAD's own class list + // (`is_plain_geometrical_constraint`) — unlike `Equal`, its class + // name never branches on entity type, so it belongs here rather + // than in the caller's per-entity-type dispatch. + ConstraintKind::EqualDistance => Some("ACEQUALDISTANCECONSTRAINT"), // `Equal` isn't one native kind — the line/circle split below // (design doc §5) needs the resolved entity types, not just the // ConstraintKind, so it's handled by the caller instead of here. @@ -664,6 +676,24 @@ fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { document.objects.remove(&root); } +/// Whether `owner`'s scope already carries a materialized native graph — +/// from an earlier save with the setting on, or from a file that came from +/// real AutoCAD/BricsCAD. Drives the "sync-if-present, create-only-if- +/// enabled" rule in [`Scene::materialize_dwg_native_constraints_for_save`]: +/// a scope that already has this graph keeps getting it rebuilt on every +/// save regardless of the current setting, so turning the setting off never +/// leaves a stale, out-of-sync graph behind — only a scope that doesn't have +/// one yet is gated by the setting. +fn has_native_network(document: &CadDocument, owner: Handle) -> bool { + let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { + return false; + }; + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dictionary_handle) else { + return false; + }; + dictionary.get(NETWORK_DICTIONARY_KEY).is_some() +} + /// One scope's worth of /// [`Scene::materialize_dwg_native_constraints_for_save`] — see that /// method's doc comment for the save-time contract this implements. @@ -784,14 +814,30 @@ impl Scene { /// "materialize right before save, from scratch every time" convention /// `sketch_persist.rs`'s `materialize_sketch_constraints_for_save` uses, /// called alongside it, not instead of it (design doc §6: additive). - pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self) { - ensure_associative_classes_registered(&mut self.document); + /// + /// `enabled` is the app's `write_dwg_native_constraints` Options toggle + /// (off by default — this graph adds file weight every save, whether or + /// not AutoCAD/BricsCAD interop is wanted). It only gates *creating* the + /// graph in a scope that doesn't have one yet: a scope that already + /// carries one — from an earlier save with the setting on, or from a + /// file that came from real AutoCAD — keeps getting it rebuilt in sync + /// regardless of the current setting ([`has_native_network`]), so + /// toggling this off never leaves a stale graph sitting in the file. + pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { + let mut classes_registered = false; for index in 0..self.sketch_constraints.len() { let set = self.sketch_constraints[index].clone(); let owner = set.scope.owner_handle(&self.document); if owner.is_null() { continue; } + if !enabled && !has_native_network(&self.document, owner) { + continue; + } + if !classes_registered { + ensure_associative_classes_registered(&mut self.document); + classes_registered = true; + } materialize_scope(&mut self.document, owner, &set, &self.named_parameters); } } @@ -801,7 +847,7 @@ impl Scene { mod tests { use super::super::sketch_constraints::SketchScope; use super::*; - use acadrust::entities::Line; + use acadrust::entities::{Circle, Line}; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -810,6 +856,10 @@ mod tests { ))) } + fn circle_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { + scene.add_entity(EntityType::Circle(Circle::from_center_radius(Vector3::new(center.0, center.1, 0.0), radius))) + } + /// Digs a scope's materialized native graph back out of `document`: /// `owner`'s extension dictionary -> `ACAD_ASSOCNETWORK` entry -> /// its one owned action, which (for this module) is always the @@ -854,7 +904,7 @@ mod tests { None, ); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; // Sanity before the round trip even happens: root + 2 geometry // nodes + 2 constraint nodes. @@ -915,7 +965,7 @@ mod tests { #[test] fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); assert!(has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization"); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) @@ -951,7 +1001,7 @@ mod tests { #[test] fn a_dxf_save_keeps_only_the_constraint_group_shell() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version) .unwrap_or_else(|e| panic!("save to dxf: {e}")); @@ -981,9 +1031,9 @@ mod tests { None, ); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let first_count = scene.document.objects.len(); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let second_count = scene.document.objects.len(); assert_eq!(first_count, second_count, "a resave with the same constraints must not accumulate new objects"); } @@ -992,7 +1042,7 @@ mod tests { fn an_empty_constraint_set_leaves_no_native_network() { let mut scene = Scene::new(); let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - scene.materialize_dwg_native_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; let dict = scene.document.extension_dictionary_handle(owner); @@ -1005,4 +1055,141 @@ mod tests { } } } + + fn model_space_has_native_network(scene: &Scene) -> bool { + has_native_network(&scene.document, scene.document.header.model_space_block_handle) + } + + #[test] + fn the_setting_off_skips_a_scope_with_no_existing_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(false); + assert!( + !model_space_has_native_network(&scene), + "a scope with no prior native graph should stay untouched while the setting is off" + ); + } + + /// The "sync-if-present, create-only-if-enabled" rule from + /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment: + /// once a scope has the native graph (here, from an earlier save with + /// the setting on), turning the setting off must not freeze that graph + /// out of date — it should keep tracking the current constraints. + #[test] + fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + scene.materialize_dwg_native_constraints_for_save(true); + assert!(model_space_has_native_network(&scene), "sanity: the graph should exist after the first, enabled save"); + + let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Vertical, + vec![SketchRef::whole(b)], + None, + ); + scene.materialize_dwg_native_constraints_for_save(false); + + let owner = scene.document.header.model_space_block_handle; + let group = native_group(&scene.document, owner); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACVERTICALCONSTRAINT"), + "the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}", + group.nodes.iter().map(|n| &n.class_name).collect::>() + ); + } + + /// The other half of the same rule: disabling the setting after a scope + /// already has the graph must not make that graph disappear either — + /// only remove it by clearing the scope's constraints (existing + /// `an_empty_constraint_set_leaves_no_native_network` behavior), not by + /// toggling the setting off. + #[test] + fn the_setting_off_does_not_remove_an_existing_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(a)], + None, + ); + scene.materialize_dwg_native_constraints_for_save(true); + scene.materialize_dwg_native_constraints_for_save(false); + assert!( + model_space_has_native_network(&scene), + "an existing native graph must survive a disabled-setting resave, not be silently dropped" + ); + } + + /// The 8 new `ConstraintKind`s all reuse the same generic + /// `Geometrical`-shaped node path `geometrical_class_name` drives (see + /// its own doc comment) — one representative mix of ref shapes (2 whole + /// entities, an asymmetric point+whole pair, and a 3-ref case) is + /// enough to confirm that path handles them all, without one dedicated + /// test per kind. + #[test] + fn new_constraint_kinds_round_trip_with_their_own_dwg_native_class_names() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let circle_a = circle_entity(&mut scene, (0.0, 0.0), 3.0); + let circle_b = circle_entity(&mut scene, (5.0, 5.0), 1.0); + let line_a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let line_b = line_entity(&mut scene, (3.0, 4.0), (7.0, 6.0)); + let marker = line_entity(&mut scene, (20.0, 20.0), (21.0, 21.0)); + let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Concentric, vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], None); + set.add(ConstraintKind::Colinear, vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None); + set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], None); + set.add(ConstraintKind::Midpoint, vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], None); + set.add(ConstraintKind::PointOnCurve, vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], None); + set.add( + ConstraintKind::EqualDistance, + vec![SketchRef::point(line_b, 0), SketchRef::point(line_b, 1), SketchRef::point(line_a, 0), SketchRef::point(line_a, 1)], + None, + ); + set.add( + ConstraintKind::Symmetric, + vec![SketchRef::center(circle_a), SketchRef::center(circle_b), SketchRef::whole(axis)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("new_kinds.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + for expected in [ + "ACCONCENTRICCONSTRAINT", + "ACCOLINEARCONSTRAINT", + "ACFIXEDCONSTRAINT", + "ACCENTERPOINTCONSTRAINT", + "ACMIDPOINTCONSTRAINT", + "ACPOINTCURVECONSTRAINT", + "ACEQUALDISTANCECONSTRAINT", + "ACSYMMETRICCONSTRAINT", + ] { + assert!(class_names.contains(&expected), "{ext}: missing {expected} node, got {class_names:?}"); + } + } + } } diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index a3ece49ce..2700a3ea0 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -79,6 +79,41 @@ pub enum ConstraintKind { Angle, Radius, Tangent, + /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. + /// Solves identically to `Coincident` (`sketch_solve.rs` broadens that + /// match arm rather than duplicating it) — only the DWG-native class + /// name (`ACCONCENTRICCONSTRAINT` vs `ACPOINTCOINCIDENCECONSTRAINT`) + /// and the UI entry point differ. + Concentric, + /// A point sits at a circle/arc's center — `refs`: `[point, center(circle)]`. + /// Same solver math as `Coincident`/`Concentric`, different DWG class + /// name (`ACCENTERPOINTCONSTRAINT`). + CenterPoint, + /// Two lines share the same infinite line — `refs`: `[whole(a), whole(b)]`. + Colinear, + /// A point sits at another line's midpoint — `refs`: `[point, whole(line)]`. + Midpoint, + /// Locks a whole entity at its current position — `refs`: `[whole(entity)]`. + /// No `driving_param`: the target is the entity's own live geometry at + /// solve time, not a typed value (see `sketch_solve.rs`'s `Fixed` arm). + Fixed, + /// A point lies anywhere along a line's or circle's curve (not + /// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`. + PointOnCurve, + /// The distance between one point pair equals the distance between + /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). + /// AutoCAD's fourth `Equal` sub-kind (`ACEQUALDISTANCECONSTRAINT`) — + /// see the design doc for why `EqualCurvature`, the other missing + /// sub-kind, isn't modeled: for the only entity types this system + /// solves (Line, Circle), it would be mathematically identical to this + /// `Equal`'s existing circle/circle (radius) branch. + EqualDistance, + /// Two circles/arcs are mirror images of each other across a line — + /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the + /// circle-center-pair case (unambiguous with whole-entity selection); + /// point-symmetry about a point, and symmetry between two lines, aren't + /// modeled. + Symmetric, } pub type ConstraintId = u32; @@ -345,6 +380,14 @@ impl ConstraintKind { ConstraintKind::Angle => "∠", ConstraintKind::Radius => "R", ConstraintKind::Tangent => "T", + ConstraintKind::Concentric => "◎", + ConstraintKind::CenterPoint => "⊕", + ConstraintKind::Colinear => "L", + ConstraintKind::Midpoint => "M", + ConstraintKind::Fixed => "F", + ConstraintKind::PointOnCurve => "∈", + ConstraintKind::EqualDistance => "≐", + ConstraintKind::Symmetric => "S", } } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index cd9d3a755..e88519b50 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -29,9 +29,10 @@ use acadrust::entities::EntityType; use acadrust::types::Handle; use ocs_gcs::constraints::angle_distance::L2LAngle; -use ocs_gcs::constraints::circle_arc::{C2LDistance, TangentCircumf}; +use ocs_gcs::constraints::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; use ocs_gcs::constraints::point_line::{ - Equal, EqualLineLength, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, P2PDistance, + CenterOfGravity, Equal, EqualLineLength, MidpointOnLine, Parallel as ParallelConstraint, + Perpendicular as PerpendicularConstraint, PointOnLine, P2PDistance, }; use ocs_gcs::constraints::Constraint; use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint}; @@ -111,14 +112,18 @@ fn resolve_ref( Some(*cache.get(&r.entity)?) } -/// One [`SketchConstraint`]'s `ocs_gcs` construction, or `None` if it can't -/// be built (an unsupported/not-yet-mapped kind, a dangling ref, a ref -/// shape the kind doesn't expect — e.g. `Parallel` needs two whole-line -/// refs — or, for a dimensional kind, a `driving_param` that fails to -/// resolve: an undefined named-parameter reference or a division by zero in -/// its formula, design doc `named_parameters_design.md` stage 3). A -/// constraint that can't be built is simply skipped for this solve, not an -/// error: geometry it would have constrained is left alone, matching how a +/// One [`SketchConstraint`]'s `ocs_gcs` construction: every system-level +/// constraint it contributes (most kinds contribute exactly one; a few — +/// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two +/// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, +/// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be +/// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape +/// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, +/// for a dimensional kind, a `driving_param` that fails to resolve: an +/// undefined named-parameter reference or a division by zero in its +/// formula, design doc `named_parameters_design.md` stage 3). A constraint +/// that can't be built is simply skipped for this solve, not an error: +/// geometry it would have constrained is left alone, matching how a /// dangling associative-dimension reference degrades today rather than /// aborting the whole recompute. (A named-parameter *cycle* should be /// unreachable here — `ParameterTable::set` already refuses to create one — @@ -130,9 +135,9 @@ fn build_constraint( cache: &mut HashMap, params: &ParameterTable, c: &SketchConstraint, -) -> Option> { +) -> Vec> { if !c.enabled { - return None; + return Vec::new(); } let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? { @@ -148,64 +153,159 @@ fn build_constraint( resolve_ref(document, sys, cache, r)?.point_for_marker(marker) }; + // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two + // points (an endpoint, a circle's center via the existing `-3` marker, + // or a plain point) held equal on both axes. Only the DWG-native class + // name and the UI entry point that produces their `refs` differ. + let point_pair_equal = |sys: &mut System, cache: &mut HashMap<_, _>, refs: &[SketchRef]| -> Vec> { + let [a, b] = refs else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(Equal::new(pa.x, pb.x, 1.0)), Rc::new(Equal::new(pa.y, pb.y, 1.0))] + }; + match c.kind { - ConstraintKind::Coincident => { - let [a, b] = c.refs.as_slice() else { return None }; - let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?); - // Two coordinate-equal constraints packaged as one via a small - // local combinator isn't worth it for a single call site — the - // caller (`solve_scope`) already `extend`s a `Vec` per - // constraint, so this returns just the X constraint and Y is - // added as a second, independent `SketchConstraint`-less - // push — see `solve_scope`'s handling of this arm specifically. - Some(Rc::new(Equal::new(pa.x, pb.x, 1.0))) + ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { + point_pair_equal(sys, cache, &c.refs) } ConstraintKind::Horizontal => { - let l = whole_line(sys, cache, *c.refs.first()?)?; - Some(Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))) + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(l) = whole_line(sys, cache, *r) else { return Vec::new() }; + vec![Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))] } ConstraintKind::Vertical => { - let l = whole_line(sys, cache, *c.refs.first()?)?; - Some(Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0))) + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(l) = whole_line(sys, cache, *r) else { return Vec::new() }; + vec![Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0))] } ConstraintKind::Parallel => { - let [a, b] = c.refs.as_slice() else { return None }; - let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?); - Some(Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] } ConstraintKind::Perpendicular => { - let [a, b] = c.refs.as_slice() else { return None }; - let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?); - Some(Rc::new(PerpendicularConstraint::new(sys.store(), moving, fixed))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(PerpendicularConstraint::new(sys.store(), moving, fixed))] } ConstraintKind::Equal => { - let [a, b] = c.refs.as_slice() else { return None }; + let [a, b] = c.refs.as_slice() else { return Vec::new() }; if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { - return Some(Rc::new(EqualLineLength::new(lb, la))); + return vec![Rc::new(EqualLineLength::new(lb, la))]; + } + let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) else { return Vec::new() }; + vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] + } + ConstraintKind::EqualDistance => { + let [a, b, c2, d] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb), Some(pc), Some(pd)) = + (point_ref(sys, cache, *a), point_ref(sys, cache, *b), point_ref(sys, cache, *c2), point_ref(sys, cache, *d)) + else { + return Vec::new(); + }; + vec![Rc::new(EqualLineLength::new(GLine { p1: pc, p2: pd }, GLine { p1: pa, p2: pb }))] + } + ConstraintKind::Colinear => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(onto), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + // Pinning both of `moving`'s endpoints onto `onto`'s infinite + // line forces the two to coincide (as long as `moving`'s own + // two points stay distinct) — no dedicated "colinear" primitive + // needed, `PointOnLine` applied twice does it. + vec![Rc::new(PointOnLine::new(moving.p1, onto)), Rc::new(PointOnLine::new(moving.p2, onto))] + } + ConstraintKind::Midpoint => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + vec![ + Rc::new(CenterOfGravity::new(p.x, vec![l.p1.x, l.p2.x], vec![0.5, 0.5])), + Rc::new(CenterOfGravity::new(p.y, vec![l.p1.y, l.p2.y], vec![0.5, 0.5])), + ] + } + ConstraintKind::PointOnCurve => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(p), Some(geom)) = (point_ref(sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + return Vec::new(); + }; + match geom { + EntityGeom::Line(l) => vec![Rc::new(PointOnLine::new(p, l))], + EntityGeom::Circle(circ) => { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new(circ, p, zero))] + } + } + } + ConstraintKind::Symmetric => { + let [a, b, m] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb), Some(mirror)) = + (point_ref(sys, cache, *a), point_ref(sys, cache, *b), whole_line(sys, cache, *m)) + else { + return Vec::new(); + }; + let pair = GLine { p1: pa, p2: pb }; + vec![ + Rc::new(MidpointOnLine::new(pair, mirror)), + Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + ] + } + ConstraintKind::Fixed => { + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(geom) = resolve_ref(document, sys, cache, *r) else { + return Vec::new(); + }; + match geom { + EntityGeom::Line(l) => { + let (x1, y1, x2, y2) = { + let store = sys.store(); + (store.get(l.p1.x), store.get(l.p1.y), store.get(l.p2.x), store.get(l.p2.y)) + }; + vec![ + Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), + ] + } + EntityGeom::Circle(circ) => { + let (cx, cy, r) = { + let store = sys.store(); + (store.get(circ.center.x), store.get(circ.center.y), store.get(circ.rad)) + }; + vec![ + Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), + ] + } } - let (ca, cb) = (whole_circle(sys, cache, *a)?, whole_circle(sys, cache, *b)?); - Some(Rc::new(Equal::new(cb.rad, ca.rad, 1.0))) } ConstraintKind::Distance => { - let [a, b] = c.refs.as_slice() else { return None }; - let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?); - let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true); - Some(Rc::new(P2PDistance::new(pa, pb, target))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![Rc::new(P2PDistance::new(pa, pb, target))] } ConstraintKind::Angle => { - let [a, b] = c.refs.as_slice() else { return None }; - let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?); - let angle = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?.to_radians(), true); - Some(Rc::new(L2LAngle::new(fixed, moving, angle))) + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let angle = sys.add_param(resolved.to_radians(), true); + vec![Rc::new(L2LAngle::new(fixed, moving, angle))] } ConstraintKind::Radius => { - let circle = whole_circle(sys, cache, *c.refs.first()?)?; - let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true); - Some(Rc::new(Equal::new(circle.rad, target, 1.0))) + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(circle) = whole_circle(sys, cache, *r) else { + return Vec::new(); + }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![Rc::new(Equal::new(circle.rad, target, 1.0))] } ConstraintKind::Tangent => { - let [a, b] = c.refs.as_slice() else { return None }; - let (ga, gb) = (resolve_ref(document, sys, cache, *a)?, resolve_ref(document, sys, cache, *b)?); + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(ga), Some(gb)) = (resolve_ref(document, sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + return Vec::new(); + }; match (ga, gb) { (EntityGeom::Circle(c1), EntityGeom::Circle(c2)) => { let store = sys.store(); @@ -217,7 +317,7 @@ fn build_constraint( // the first solve doesn't have to cross the singularity // between the two tangency configurations. let internal = center_dist < (r1 - r2).abs(); - Some(Rc::new(TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, internal))) + vec![Rc::new(TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, internal))] } (EntityGeom::Circle(circ), EntityGeom::Line(line)) | (EntityGeom::Line(line), EntityGeom::Circle(circ)) => { let store = sys.store(); @@ -234,11 +334,11 @@ fn build_constraint( // (see its `error_grad`), i.e. plain tangency rather than // an offset distance. let zero = sys.add_param(0.0, true); - Some(Rc::new(C2LDistance::new(circ, line, zero, ccw, false))) + vec![Rc::new(C2LDistance::new(circ, line, zero, ccw, false))] } // Line-Line tangency has no meaning; Arc isn't a supported // `EntityGeom` yet (see that type's own doc comment). - _ => None, + _ => Vec::new(), } } } @@ -265,35 +365,20 @@ fn solve_scope( ) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> { let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); - // Tracks which system-level `ocs_gcs` constraint came from which + // Tracks which system-level `ocs_gcs` constraint(s) came from which // `SketchConstraint` — a `SubSystem`'s redundant-row indices (from // `ocs_gcs::diagnosis`) are local to that partition's own constraint - // list, not `set.constraints`' indices, and Coincident contributes two - // system-level constraints (X and Y halves) for one `SketchConstraint`. - // `Rc::ptr_eq` against this after partitioning resolves a row back to - // the `ConstraintId` the UI actually names. + // list, not `set.constraints`' indices, and several kinds contribute + // more than one system-level constraint per `SketchConstraint` (see + // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after + // partitioning resolves a row back to the `ConstraintId` the UI + // actually names. let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); for c in &set.constraints { - let Some(constraint) = build_constraint(document, &mut sys, &mut cache, params, c) else { continue }; - owner.push((constraint.clone(), c.id)); - sys.add_constraint(constraint); - // Coincident needs both X and Y equal; `build_constraint` returns - // only the X half (see its doc comment there) since one - // `SketchConstraint` maps to one `Rc` everywhere - // else — add the Y half here instead of complicating that - // one-constraint-per-kind contract for a single kind. - if c.kind == ConstraintKind::Coincident { - if let [a, b] = c.refs.as_slice() { - if let (Some(pa), Some(pb)) = ( - a.marker.and_then(|m| cache.get(&a.entity).and_then(|g| g.point_for_marker(m))), - b.marker.and_then(|m| cache.get(&b.entity).and_then(|g| g.point_for_marker(m))), - ) { - let y_half: Rc = Rc::new(Equal::new(pa.y, pb.y, 1.0)); - owner.push((y_half.clone(), c.id)); - sys.add_constraint(y_half); - } - } + for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { + owner.push((constraint.clone(), c.id)); + sys.add_constraint(constraint); } } diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 0e3957701..873749af5 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -31,6 +31,7 @@ impl fmt::Display for Labelled { pub fn view_window<'a>( default_save_format: &'a str, file_assoc_enabled: bool, + write_dwg_native_constraints: bool, ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], language: crate::i18n::Language, @@ -190,6 +191,24 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), + Space::new().height(14), + row![ + iced::widget::checkbox(write_dwg_native_constraints) + .on_toggle(Message::WriteDwgNativeConstraintsChanged) + .size(15), + text(crate::t!("Write AutoCAD-compatible constraint objects on save")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Saves sketch constraints as AutoCAD/BricsCAD's own native objects too, alongside \ + this app's own format, so other CAD software recognizes them. Off by default — \ + adds some file size on every save. A drawing that already has these objects keeps \ + them up to date regardless of this setting." + )) + .size(11) + .width(sizing.width), ] .spacing(0) .width(sizing.width); diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index 2c9f025d2..d2838eb73 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -354,3 +354,212 @@ fn two_conflicting_distance_targets_are_reported_as_conflicting() { assert!(flagged_id == first || flagged_id == second, "the flagged id should be one of the two conflicting Distance constraints"); assert_eq!(kind, RedundancyKind::Conflicting); } + +fn set_circle_center(scene: &mut Scene, handle: Handle, center: Vector3) { + if let Some(EntityType::Circle(c)) = scene.document.get_entity_mut(handle) { + c.center = center; + } +} + +fn set_line_start(scene: &mut Scene, handle: Handle, start: Vector3) { + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(handle) { + l.start = start; + } +} + +#[test] +fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() { + let mut scene = Scene::new(); + let a = add_circle(&mut scene, 0.0, 0.0, 3.0); + let b = add_circle(&mut scene, 5.0, 5.0, 1.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(a), SketchRef::center(b)], + None, + ); + + set_circle_center(&mut scene, a, Vector3::new(2.0, -3.0, 0.0)); + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let (ca, ra) = circle_geom(&scene, a); + let (cb, rb) = circle_geom(&scene, b); + assert!((ca.x - cb.x).abs() < 1e-6 && (ca.y - cb.y).abs() < 1e-6, "centers should coincide: a={ca:?} b={cb:?}"); + // Radii are untouched by Concentric — only centers move. + assert!((ra - 3.0).abs() < 1e-6 && (rb - 1.0).abs() < 1e-6, "radii must not change: ra={ra} rb={rb}"); +} + +#[test] +fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 4.0); + let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(circle)], + None, + ); + + set_circle_center(&mut scene, circle, Vector3::new(-6.0, 9.0, 0.0)); + scene.bump_entities(&[(circle, ChangeKind::Modified)]); + + let (center, _) = circle_geom(&scene, circle); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (center.x - line_start.x).abs() < 1e-6 && (center.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the circle's center: center={center:?} start={line_start:?}" + ); +} + +#[test] +fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Colinear, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + // Both of b's endpoints should land on a's infinite line: the signed + // area of (a2-a1) × (bN-a1) is ~0 for a point exactly on that line. + let area = |p: Vector3| (a2.x - a1.x) * (p.y - a1.y) - (a2.y - a1.y) * (p.x - a1.x); + assert!(area(b1).abs() < 1e-5, "b.start should land on a's line, area={}", area(b1)); + assert!(area(b2).abs() < 1e-5, "b.end should land on a's line, area={}", area(b2)); +} + +#[test] +fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { + let mut scene = Scene::new(); + let base = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Midpoint, + vec![SketchRef::point(marker, 0), SketchRef::whole(base)], + None, + ); + + set_line_end(&mut scene, base, Vector3::new(10.0, 8.0, 0.0)); + scene.bump_entities(&[(base, ChangeKind::Modified)]); + + let (b1, b2) = line_endpoints(&scene, base); + let (marker_start, _) = line_endpoints(&scene, marker); + let expected = Vector3::new((b1.x + b2.x) / 2.0, (b1.y + b2.y) / 2.0, 0.0); + assert!( + (marker_start.x - expected.x).abs() < 1e-6 && (marker_start.y - expected.y).abs() < 1e-6, + "marker point should sit at base's midpoint: expected={expected:?} got={marker_start:?}" + ); +} + +#[test] +fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { + let mut scene = Scene::new(); + let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(fixed_line)], None); + set.add(ConstraintKind::Coincident, vec![SketchRef::point(fixed_line, 1), SketchRef::point(moving_line, 0)], None); + + let (before_start, before_end) = line_endpoints(&scene, fixed_line); + + // Drag the shared point — without Fixed this would pull fixed_line's + // end along with it; Fixed should hold fixed_line exactly in place and + // let moving_line's start follow back to it instead. + set_line_start(&mut scene, moving_line, Vector3::new(15.0, 5.0, 0.0)); + scene.bump_entities(&[(moving_line, ChangeKind::Modified)]); + + let (after_start, after_end) = line_endpoints(&scene, fixed_line); + assert_eq!(before_start, after_start, "Fixed entity's start must not move"); + assert_eq!(before_end, after_end, "Fixed entity's end must not move"); + let (moving_start, _) = line_endpoints(&scene, moving_line); + assert!( + (moving_start.x - before_end.x).abs() < 1e-6 && (moving_start.y - before_end.y).abs() < 1e-6, + "moving_line's start should have been pulled back to fixed_line's (unmoved) end" + ); +} + +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 5.0); + let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], + None, + ); + + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, radius) = circle_geom(&scene, circle); + let (marker_start, _) = line_endpoints(&scene, marker); + let dist = ((marker_start.x - center.x).powi(2) + (marker_start.y - center.y).powi(2)).sqrt(); + assert!((dist - radius).abs() < 1e-5, "point should land on the circumference: dist={dist} radius={radius}"); +} + +#[test] +fn equal_distance_constraint_matches_a_second_point_pairs_separation() { + let mut scene = Scene::new(); + // Reference pair: fixed 6 units apart. + let a = add_line(&mut scene, 0.0, 0.0, 6.0, 0.0); + // Tracked pair: starts at some other separation, should be pulled to 6. + let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::EqualDistance, + vec![SketchRef::point(b, 0), SketchRef::point(b, 1), SketchRef::point(a, 0), SketchRef::point(a, 1)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + let dist_a = ((a2.x - a1.x).powi(2) + (a2.y - a1.y).powi(2)).sqrt(); + let dist_b = ((b2.x - b1.x).powi(2) + (b2.y - b1.y).powi(2)).sqrt(); + assert!((dist_a - dist_b).abs() < 1e-6, "the two pairs' separations should match: dist_a={dist_a} dist_b={dist_b}"); +} + +#[test] +fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { + let mut scene = Scene::new(); + let axis = add_line(&mut scene, 0.0, 0.0, 0.0, 10.0); // the Y axis + let a = add_circle(&mut scene, 3.0, 4.0, 1.0); + // Starts well off the mirrored position (and not coincident with `a` — + // a zero-length a/b segment would leave `Perpendicular`'s precomputed + // scale dividing by zero at the very first solve iteration). + let b = add_circle(&mut scene, 8.0, 9.0, 1.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + // Pin the axis and `a` in place — otherwise they're just as free to move + // as `b`'s center, and with only 2 equations (MidpointOnLine + + // Perpendicular) against that many more unknowns the solver is free to + // converge on any of infinitely many valid configurations, not + // necessarily "only b moves, to a's exact mirror" (which is the one + // deterministic outcome this test actually wants to check). + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); + set.add(ConstraintKind::Symmetric, vec![SketchRef::center(a), SketchRef::center(b), SketchRef::whole(axis)], None); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (ca, _) = circle_geom(&scene, a); + let (cb, _) = circle_geom(&scene, b); + assert_eq!(ca, Vector3::new(3.0, 4.0, 0.0), "Fixed a's center must not have moved"); + // With both a and the axis pinned, b's center has a unique valid + // position left: a's exact mirror across the Y axis, (-3, 4). + assert!( + (cb.x - -3.0).abs() < 1e-5 && (cb.y - 4.0).abs() < 1e-5, + "b's center should have been pulled to a's mirror image across the axis: expected (-3, 4), got {cb:?}" + ); +} From eb96e8b6ed02d915be3f18e5ac60e77765507387 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Wed, 9 Sep 2026 21:16:58 +0200 Subject: [PATCH 08/99] =?UTF-8?q?Fix=20silent=20TTF-fallback=20failures=20?= =?UTF-8?q?for=20variable=20system=20fonts=20(Turkish=20'=C4=9E')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (found by instrumenting build_fallback and reading ttf-parser's own source, not guessed): cosmic-text's fallback search picks macOS's system font (San Francisco / SFNS.ttf) for any codepoint no stroke font or explicitly-requested family covers. It's a variable TrueType font, and ttf-parser's outline_glyph() resolves a variable glyph's contours through its gvar table's deltas relative to whatever variation coordinates are currently set on the Face — an unset Face has none, so outline_glyph silently returns None (not an error) for such a glyph. The caller then still returned Some(Glyph { strokes: vec![], .. }): a "resolved" glyph with nothing to render, no error anywhere. Two-part fix: 1. pin_default_variations() sets every variation axis to its own default value before outlining, applied at all TTF outline- extraction sites (both build_fallback variants, both build_shaped variants, and the plain by-family glyph() lookup). This alone wasn't sufficient for San Francisco specifically — even pinned, its outline still didn't resolve for the glyphs this bug was found on, apparently because ttf-parser can't retrieve base glyf data gvar needs for at least some of that font's accented Latin glyphs. 2. So build_fallback() now treats "resolved a glyph ID but couldn't extract its outline" as a real failure (outline_from_face returns None, not an empty glyph) and falls through to a direct fontdb scan for any other installed font that also covers the character and can actually be outlined, rather than silently giving up. Full workspace test suite: 813 passed, 0 failed (previously 812 passed, 1 failed — this was the one pre-existing failure carried through the entire session). Co-Authored-By: Claude Sonnet 5 --- src/scene/text/ttf_glyph.rs | 106 ++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 17 deletions(-) diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 1d16be722..9fb843855 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -153,8 +153,9 @@ pub fn glyph(family: &str, ch: char) -> Option> { } let built = sysfont::with_face_data(family, |data, index| { - let face = ttf_parser::Face::parse(data, index).ok()?; + let mut face = ttf_parser::Face::parse(data, index).ok()?; let gid = face.glyph_index(ch)?; + pin_default_variations(&mut face); let k = cap_scale(&face); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; @@ -338,11 +339,12 @@ fn build_fallback(ch: char) -> Option> { let script = crate::scene::text::web_font::script_of(ch)?; let bytes = crate::scene::text::web_font::request(script)?; let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1); - let (face, gid) = (0..face_count).find_map(|face_index| { + let (mut face, gid) = (0..face_count).find_map(|face_index| { let face = ttf_parser::Face::parse(&bytes, face_index).ok()?; let gid = face.glyph_index(ch)?; Some((face, gid)) })?; + pin_default_variations(&mut face); let k = cap_scale(&face); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let mut fl = OutlineFlattener::new(k); @@ -359,6 +361,46 @@ fn build_fallback(ch: char) -> Option> { })) } +/// A variable font (e.g. macOS's system font, San Francisco — cosmic-text's +/// own fallback search happily picks it for a codepoint no static font +/// nearby covers) needs every variation axis pinned before its outline is +/// readable: `ttf-parser` resolves a variable glyph's contours through its +/// `gvar` deltas relative to whatever coordinates are currently set on the +/// `Face`, and an unset `Face` has none — `outline_glyph` then silently +/// returns `None` (not an error) for such a glyph, which upstream code was +/// treating as "this glyph has no outline" (e.g. a space) rather than "this +/// extraction failed", producing a `Some(Glyph { strokes: vec![], .. })` +/// that renders nothing without ever reporting a problem. Setting each axis +/// to its own default value (`def_value`) reproduces the font's un-varied, +/// default instance — the same shape a non-variable static font would give. +fn pin_default_variations(face: &mut ttf_parser::Face) { + if !face.is_variable() { + return; + } + for axis in face.variation_axes() { + let _ = face.set_variation(axis.tag, axis.def_value); + } +} + +/// Outlines `gid` from an already-parsed, variation-pinned `face` — the +/// shared last step both [`build_fallback`] candidates below use. `None` +/// (not just an empty glyph) whenever `outline_glyph` itself reports no +/// contours, so a caller can tell "this font can't actually render this +/// glyph" apart from "this glyph is legitimately blank" and keep looking — +/// see [`pin_default_variations`]'s doc comment for why a resolved glyph ID +/// doesn't guarantee an outline comes back. +fn outline_from_face(face: &ttf_parser::Face, gid: ttf_parser::GlyphId, k: f32) -> Option> { + let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; + let mut fl = OutlineFlattener::new(k); + face.outline_glyph(gid, &mut fl); + fl.flush(); + if fl.contours.is_empty() { + return None; + } + let fill_tris = triangulate_contours(&fl.contours); + Some(Arc::new(Glyph { strokes: fl.contours, advance, fill_tris })) +} + #[cfg(not(target_arch = "wasm32"))] fn build_fallback(ch: char) -> Option> { use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; @@ -371,26 +413,54 @@ fn build_fallback(ch: char) -> Option> { buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None); buf.shape_until_scroll(&mut fs, false); + let mut tried: Vec = Vec::new(); for run in buf.layout_runs() { for g in run.glyphs.iter() { if g.glyph_id == 0 { continue; // .notdef — this font doesn't really cover it } + tried.push(g.font_id); let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); - let font = fs.get_font(g.font_id, g.font_weight)?; - let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; + let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue }; + let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { continue }; + pin_default_variations(&mut face); let k = cap_scale(&face); - let gid = ttf_parser::GlyphId(g.glyph_id); - let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; - let mut fl = OutlineFlattener::new(k); - face.outline_glyph(gid, &mut fl); - fl.flush(); - let fill_tris = triangulate_contours(&fl.contours); - return Some(Arc::new(Glyph { - strokes: fl.contours, - advance, - fill_tris, - })); + if let Some(glyph) = outline_from_face(&face, ttf_parser::GlyphId(g.glyph_id), k) { + return Some(glyph); + } + // cosmic-text resolved a real glyph here, but this font's outline + // couldn't actually be extracted — confirmed (design doc-worthy, + // not a guess) on this dev machine for macOS's system font + // (San Francisco): it's a variable TrueType font whose `gvar` + // outline path needs base `glyf` glyph data `ttf-parser` can't + // retrieve for at least some of its accented Latin glyphs, even + // after every variation axis is pinned to its default. Rather + // than silently rendering nothing, fall through to a direct + // database scan below for any other installed font that also + // covers `ch` and can actually be outlined. + } + } + + // Second-chance search: cosmic-text's own fallback chose one font (and + // it failed to outline above); look for a different installed font that + // also covers `ch`, skipping whatever was already tried. Bounded by the + // database size, not attempted per keystroke — `fallback_glyph`'s own + // cache means this only runs once per distinct character ever asked + // for. + let candidates: Vec<(fontdb::ID, u32)> = fs + .db() + .faces() + .filter(|f| !tried.contains(&f.id)) + .map(|f| (f.id, f.index)) + .collect(); + for (id, index) in candidates { + let Some(font) = fs.get_font(id, cosmic_text::fontdb::Weight::NORMAL) else { continue }; + let Ok(mut face) = ttf_parser::Face::parse(font.data(), index) else { continue }; + let Some(gid) = face.glyph_index(ch) else { continue }; + pin_default_variations(&mut face); + let k = cap_scale(&face); + if let Some(glyph) = outline_from_face(&face, gid, k) { + return Some(glyph); } } None @@ -456,9 +526,10 @@ fn build_shaped(_family: &str, text: &str) -> Option { let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else { continue; }; - let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else { + let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { continue; }; + pin_default_variations(&mut face); let scale = (SHAPE_FS / face.units_per_em() as f32) * px_to_9; let pen_x = (glyph.x + SHAPE_FS * glyph.x_offset) * px_to_9; let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9; @@ -525,9 +596,10 @@ fn build_shaped(family: &str, text: &str) -> Option { let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue; }; - let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else { + let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { continue; }; + pin_default_variations(&mut face); let upem_g = face.units_per_em() as f32; // Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units. let scale_g = (SHAPE_FS / upem_g) * px_to_9; From 3d0a41b69a237548281288f4fa15b894dc3399fe Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 10:58:21 +0200 Subject: [PATCH 09/99] Extend sketch constraints toward FreeCAD parity, staying DWG-native-compatible Arcs are now full constraint participants (center/radius via the existing Circle-shaped solver path, plus real endpoints kept consistent by arc-rules CurveValue constraints); adds Diameter/DistanceX/DistanceY/ArcLength/Normal constraint kinds and initial Ellipse entity support (center-based constraints only). DWG-native class mappings and gaps (ArcLength, Ellipse) are grounded against real ObjectARX headers rather than guessed, so files saved with DWG-native constraints stay honest about what AutoCAD can actually represent. Also fixes a case-sensitivity bug where a lowercase named-parameter reference typed at a dimensional constraint's value prompt was rejected (the command line uppercases SingleToken input before the parameter-name lookup), and adds a "Used by" column to the Named Parameters panel. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 60 ++++ src/app/commands/draw.rs | 3 +- src/app/view/modal.rs | 4 +- src/modules/draw/constrain/mod.rs | 6 +- src/modules/draw/constrain/tools.rs | 20 ++ src/modules/draw/constrain/value.rs | 253 +++++++++++++++-- src/modules/draw/mod.rs | 3 +- src/scene/dwg_native_constraints.rs | 341 +++++++++++++++++++++- src/scene/sketch_constraints.rs | 139 +++++++++ src/scene/sketch_solve.rs | 339 ++++++++++++++++++++-- src/ui/window/named_parameters.rs | 126 +++++++- tests/sketch_constraints_solve.rs | 426 ++++++++++++++++++++++++++++ 12 files changed, 1660 insertions(+), 60 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d40cbc73d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +Notable changes on `feature/parametric-constraint-system` since each push to +GitHub. Newest entries first. "Unreleased" covers local changes not yet +pushed. + +## Unreleased (since `eb96e8b6`) + +- **Fix:** a dimensional constraint's value prompt (e.g. `DCONSTRAINT`'s + "Specify distance:") rejected a named parameter typed in its own + lowercase name — the command line uppercases typed text before it + reaches the parser, so `r` became `R` and no longer matched the + parameter table's case-sensitive lookup. `parse_driving_value` now + matches parameter names case-insensitively and resolves to the + parameter's canonical name. + ([value.rs](src/modules/draw/constrain/value.rs)) +- **Feature:** Named Parameters panel gains a "Used by" column showing + which constraints (kind + entity handles) currently reference each + parameter, with a hover tooltip listing the full per-constraint entity + breakdown when more than a couple of constraints use it. Backed by a new + `Scene::parameter_usage(name)` query that searches every sketch scope + (model space and blocks) and deduplicates an entity referenced by more + than one of a constraint's point markers. + ([named_parameters.rs](src/ui/window/named_parameters.rs), + [sketch_constraints.rs](src/scene/sketch_constraints.rs)) +- Named Parameters modal widened (620 → 820) to fit the new column. + ([modal.rs](src/app/view/modal.rs)) +- **Feature:** constraint-solver support extended well beyond Line/Circle, + closing most of the gap with FreeCAD's Sketcher (grounded against real + ObjectARX headers so DWG-native persistence stays honest about what + AutoCAD itself can represent): + - Arcs are now full constraint participants: center/radius (Concentric, + Tangent, Equal, CenterPoint, Radius, Diameter, Fixed, Normal) via the + same solver representation a Circle uses, *and* actual endpoints + (Coincident/PointOnCurve/Midpoint/EqualDistance/Symmetric on an arc's + real start/end point, marker 0/1) — kept numerically consistent with + center/radius/angle by "arc rules" constraints added for every + registered arc, reusing an already-ported-but-unused `ocs_gcs` + primitive (`CurveValue`) rather than new low-level math. + - New dimensional kinds: `Diameter`, `DistanceX`, `DistanceY`, + `ArcLength` — reusing existing DWG classes (Diameter/DistanceX/Y ride + the same `ACRADIUSDIAMETERCONSTRAINT`/`ACDISTANCECONSTRAINT` objects + Radius/Distance already use, with a different mode/direction byte, + matching how AutoCAD itself represents them). `ArcLength` has no DWG + equivalent at all (confirmed against ObjectARX's own headers) and is + XRecord-only by design, not omission. + - New geometric kind: `Normal` (a line perpendicular to a circle/arc's + tangent), matching AutoCAD's real `kNormal` constraint type. + - New entity: `Ellipse` (center/Concentric/CenterPoint/Fixed only — + major/minor axis dimensional constraints and `PointOnEllipse`-based + Coincident/Tangent remain a follow-up). DWG-native persistence for + ellipse-referencing constraints is deliberately not implemented yet — + `acadrust`'s `Ellipse`/`BoundedEllipse` node variants have a different + field shape than the geometry-dependency role Circle/Arc play, and + guessing the semantics risked writing a plausible-but-wrong object + graph, so it degrades to XRecord-only instead. + ([sketch_solve.rs](src/scene/sketch_solve.rs), + [sketch_constraints.rs](src/scene/sketch_constraints.rs), + [dwg_native_constraints.rs](src/scene/dwg_native_constraints.rs), + [value.rs](src/modules/draw/constrain/value.rs)) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6b1bc0ca0..6bb19741f 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1000,7 +1000,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" => { + "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1019,6 +1019,7 @@ impl OpenCADStudio { "QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"), "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), + "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), _ => (ConstraintKind::Equal, "Equal constraint"), }; return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index bf450e75f..60cd94d7f 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -175,13 +175,15 @@ impl OpenCADStudio { ) } super::super::ModalKind::NamedParameters => { + let scene = &self.tabs[self.active_tab].scene; sized_flow( ex, - 620, + 820, 520, |flow| { crate::ui::window::named_parameters::view_window( &self.named_parameter_editor_rows, + scene, flow, ) }, diff --git a/src/modules/draw/constrain/mod.rs b/src/modules/draw/constrain/mod.rs index 652166409..e66832368 100644 --- a/src/modules/draw/constrain/mod.rs +++ b/src/modules/draw/constrain/mod.rs @@ -7,8 +7,8 @@ //! (`src/scene/sketch_solve.rs`). By how they gather input: //! - **Plain selection, no picking** — Horizontal, Vertical, Fixed (select //! one entity); Parallel, Perpendicular, Equal, Tangent, Colinear, -//! Concentric (select two); Symmetric (select three: the mirrored pair, -//! then the mirror line) — dispatch straight to +//! Concentric, Normal (select two); Symmetric (select three: the +//! mirrored pair, then the mirror line) — dispatch straight to //! `CmdResult::AddSketchConstraint` from `src/app/commands/draw.rs`. //! - **A typed value** — Distance, Angle (`value.rs`): the target value //! comes from the command line after the tool is clicked. @@ -31,6 +31,6 @@ pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; pub use point_on_entity::{center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand}; pub use tools::{ - concentric, equal, fixed, horizontal, colinear, parallel, perpendicular, symmetric, tangent, vertical, + concentric, equal, fixed, horizontal, colinear, normal, parallel, perpendicular, symmetric, tangent, vertical, }; pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand}; diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index bd3e8faff..6ed14b260 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -86,6 +86,26 @@ pub mod concentric { } } +/// A line perpendicular to a circle/arc's tangent at their point of +/// contact — for the Line/Circle-only entity model this is equivalent to +/// "the line passes through the circle's center" (a circle's radius is +/// always normal to its own tangent), so it's built from the same +/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line +/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only +/// covers line-to-line — AutoCAD's own `GeomConstraintType` enum lists +/// `kNormal` and `kPerpendicular` separately for exactly this reason. +pub mod normal { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "NRCONSTRAINT", + label: "Normal", + icon: IconKind::Glyph("⊾"), + event: ModuleEvent::Command("NRCONSTRAINT".to_string()), + } + } +} + pub mod colinear { use super::*; pub fn tool() -> ToolDef { diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index fa0afa064..256253f23 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -40,7 +40,15 @@ fn parse_driving_value(text: &str, known_names: &[String]) -> Option() { return Some(DrivingValue::Literal(value)); } - known_names.iter().find(|n| n.as_str() == text).map(|n| DrivingValue::Named(n.clone())) + // Case-insensitive: the command line uppercases `SingleToken` input + // (InputKind::SingleToken) before it reaches this parser, so a typed + // lowercase parameter name like "hole_dia" arrives here as "HOLE_DIA". + // Matching case-insensitively and returning the canonically-cased name + // keeps the driving link pointed at the actual parameter. + known_names + .iter() + .find(|n| n.eq_ignore_ascii_case(text)) + .map(|n| DrivingValue::Named(n.clone())) } pub mod distance_tool { @@ -67,35 +75,62 @@ pub mod angle_tool { } } -/// Constrains a single line's length (between its two endpoints) or a -/// circle's radius, to a typed target value. +/// Which flavor of dimensional constraint the typed value ultimately +/// drives — selectable via a prompt keyword before the value itself is +/// typed, mirroring how real AutoCAD's own `DCCONSTRAINT` offers +/// `[Xdistance/Ydistance]` or `[Diameter]` as options on the same command +/// rather than as separate tools. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DistanceMode { + /// Line length (two-point distance) or a circle/arc's radius — + /// whichever `is_circle` selects. + Auto, + X, + Y, + Diameter, + /// Arc length — only offered when `is_arc` (not a plain Circle, which + /// has no "arc length" distinct from its circumference). + ArcLength, +} + +/// Constrains a single line's length (or its X/Y-only component) between +/// its two endpoints, or a circle/arc's radius (or diameter, or — for an +/// arc specifically — its swept arc length), to a typed target value. pub struct DistanceConstraintCommand { handle: Handle, - /// Which kind this becomes once a value comes in — decided once at - /// construction from the entity's type, matching `ConstraintKind`'s own - /// split between line-length (`Distance`) and circle-radius (`Radius`). + /// Whether `handle` is a circle/arc (Radius/Diameter) or a line + /// (Distance/DistanceX/DistanceY) — decided once at construction from + /// the entity's type. is_circle: bool, + /// Whether `handle` is specifically an Arc (as opposed to a full + /// Circle) — gates the `[Arclength]` prompt option. + is_arc: bool, + mode: DistanceMode, default_value: f64, + /// The entity's current arc length, for `ArcLength` mode's `` + /// — meaningless (left at 0) unless `is_arc`. + default_arc_length: f64, /// Snapshot of `Scene::named_parameters`' names at construction time — /// see `parse_driving_value`'s doc comment for why a snapshot. known_param_names: Vec, } impl DistanceConstraintCommand { - /// `None` if `handle` isn't a Line or Circle. + /// `None` if `handle` isn't a Line, Circle, or Arc. pub fn new(scene: &Scene, handle: Handle) -> Option { let entity = scene.document.get_entity(handle)?; - let (is_circle, default_value) = match entity { + let (is_circle, is_arc, default_value, default_arc_length) = match entity { EntityType::Line(l) => { let dx = l.end.x - l.start.x; let dy = l.end.y - l.start.y; - (false, (dx * dx + dy * dy).sqrt()) + (false, false, (dx * dx + dy * dy).sqrt(), 0.0) } - EntityType::Circle(c) => (true, c.radius), + EntityType::Circle(c) => (true, false, c.radius, 0.0), + EntityType::Arc(a) => (true, true, a.radius, a.arc_length()), _ => return None, }; let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); - Some(Self { handle, is_circle, default_value, known_param_names }) + Some(Self { handle, is_circle, is_arc, mode: DistanceMode::Auto, default_value, default_arc_length, known_param_names }) } fn build(&self, target: DrivingValue) -> Option { @@ -104,14 +139,27 @@ impl DistanceConstraintCommand { return None; } } - let (kind, refs, label) = if self.is_circle { - (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint") - } else { - ( + let (kind, refs, label) = match (self.is_circle, self.mode) { + (true, DistanceMode::Diameter) => (ConstraintKind::Diameter, vec![SketchRef::whole(self.handle)], "Diameter constraint"), + (true, DistanceMode::ArcLength) if self.is_arc => { + (ConstraintKind::ArcLength, vec![SketchRef::whole(self.handle)], "Arc length constraint") + } + (true, _) => (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint"), + (false, DistanceMode::X) => ( + ConstraintKind::DistanceX, + vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + "DistanceX constraint", + ), + (false, DistanceMode::Y) => ( + ConstraintKind::DistanceY, + vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + "DistanceY constraint", + ), + (false, _) => ( ConstraintKind::Distance, vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], "Distance constraint", - ) + ), }; Some(CmdResult::AddSketchConstraint { kind, refs, driving_param: Some(target), label }) } @@ -123,7 +171,17 @@ impl CadCommand for DistanceConstraintCommand { } fn prompt(&self) -> String { - format!("Specify distance <{:.4}>: ", self.default_value) + match (self.is_circle, self.mode) { + (true, DistanceMode::Diameter) => format!("Specify diameter <{:.4}> or [Radius]: ", self.default_value * 2.0), + (true, DistanceMode::ArcLength) if self.is_arc => { + format!("Specify arc length <{:.4}> or [Radius]: ", self.default_arc_length) + } + (true, _) if self.is_arc => format!("Specify distance <{:.4}> or [Diameter/Arclength]: ", self.default_value), + (true, _) => format!("Specify distance <{:.4}> or [Diameter]: ", self.default_value), + (false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_value), + (false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_value), + (false, _) => format!("Specify distance <{:.4}> or [Xdistance/Ydistance]: ", self.default_value), + } } fn input_kind(&self) -> InputKind { @@ -135,10 +193,45 @@ impl CadCommand for DistanceConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) + let default = match self.mode { + DistanceMode::Diameter => self.default_value * 2.0, + DistanceMode::ArcLength if self.is_arc => self.default_arc_length, + _ => self.default_value, + }; + self.build(DrivingValue::Literal(default)).unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { + // Mode-switch keywords re-prompt instead of building — consumed + // inputs return `Some(NeedPoint)` so the driver doesn't re-offer + // the same token a second time (matches e.g. `trim.rs`'s + // `T`/`B`/`F` keyword handling). Defensively uppercased same as + // `trim.rs` even though `SingleToken` input already arrives + // uppercased from the command line. + let keyword = text.trim().to_uppercase(); + match (self.is_circle, keyword.as_str()) { + (true, "D" | "DIAMETER") => { + self.mode = DistanceMode::Diameter; + return Some(CmdResult::NeedPoint); + } + (true, "A" | "ARCLENGTH") if self.is_arc => { + self.mode = DistanceMode::ArcLength; + return Some(CmdResult::NeedPoint); + } + (true, "R" | "RADIUS") => { + self.mode = DistanceMode::Auto; + return Some(CmdResult::NeedPoint); + } + (false, "X" | "XDISTANCE") => { + self.mode = DistanceMode::X; + return Some(CmdResult::NeedPoint); + } + (false, "Y" | "YDISTANCE") => { + self.mode = DistanceMode::Y; + return Some(CmdResult::NeedPoint); + } + _ => {} + } let value = parse_driving_value(text, &self.known_param_names)?; self.build(value) } @@ -239,4 +332,128 @@ mod tests { assert_eq!(parse_driving_value("bogus", &known), None); assert_eq!(parse_driving_value("", &known), None); } + + #[test] + fn parse_driving_value_matches_a_known_parameter_regardless_of_typed_case() { + // The command line uppercases `SingleToken` input before it reaches + // here, so a lowercase-named parameter must still resolve — and + // resolve to its canonically-cased name, not the uppercased token. + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("HOLE_DIA", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + assert_eq!(parse_driving_value("Hole_Dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + } + + fn add_line(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(6.0, 8.0, 0.0), + ))) + } + + fn add_circle(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + ))) + } + + fn add_arc(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + 0.0, + std::f64::consts::FRAC_PI_2, + ))) + } + + #[test] + fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + + // The keyword itself only switches mode and re-prompts — it must + // not build a constraint yet. + assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("X distance"), "prompt should reflect the new mode: {}", cmd.prompt()); + + match cmd.on_text_input("10") { + Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + assert_eq!(kind, ConstraintKind::DistanceX); + assert_eq!(v, 10.0); + } + _ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"), + } + } + + #[test] + fn d_keyword_switches_a_circles_command_to_diameter_mode() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("diameter"), "prompt should reflect Diameter mode: {}", cmd.prompt()); + + match cmd.on_text_input("16") { + Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + assert_eq!(kind, ConstraintKind::Diameter); + assert_eq!(v, 16.0); + } + _ => panic!("expected an AddSketchConstraint{{Diameter}}"), + } + } + + #[test] + fn default_mode_still_builds_a_plain_radius_constraint() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + match cmd.on_text_input("5") { + Some(CmdResult::AddSketchConstraint { kind, .. }) => assert_eq!(kind, ConstraintKind::Radius), + _ => panic!("expected an AddSketchConstraint{{Radius}}"), + } + } + + #[test] + fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + + assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("arc length"), "prompt should reflect ArcLength mode: {}", cmd.prompt()); + + match cmd.on_text_input("8") { + Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + assert_eq!(kind, ConstraintKind::ArcLength); + assert_eq!(v, 8.0); + } + _ => panic!("expected an AddSketchConstraint{{ArcLength}}"), + } + } + + #[test] + fn arc_length_keyword_is_not_offered_for_a_plain_circle() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(!cmd.prompt().contains("Arclength"), "a plain circle has no arc length option: {}", cmd.prompt()); + // "A" isn't a recognized keyword here and doesn't parse as a + // number or a named parameter either, so it's rejected outright. + assert!(cmd.on_text_input("A").is_none()); + } + + #[test] + fn arc_length_default_is_the_arcs_current_swept_length() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 + let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + cmd.on_text_input("A"); + + let expected = 3.0 * std::f64::consts::FRAC_PI_2; + assert!(cmd.prompt().contains(&format!("{expected:.4}")), "prompt should default to the arc's current length: {}", cmd.prompt()); + } } diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 82882fb99..23f8f5bb0 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -36,7 +36,7 @@ impl CadModule for DrawModule { use clipboard::{copy_clip, cut, paste}; use constrain::{ angle_tool, center_point_tool, coincident_tool, colinear, concentric, distance_tool, equal, - equal_distance_tool, fixed, horizontal, midpoint_tool, parallel, perpendicular, point_on_curve_tool, + equal_distance_tool, fixed, horizontal, midpoint_tool, normal, parallel, perpendicular, point_on_curve_tool, symmetric, tangent, vertical, }; use draw::{arc, circle, ellipse, hatch, line, polyline, shapes}; @@ -135,6 +135,7 @@ impl CadModule for DrawModule { perpendicular::tool().into(), equal::tool().into(), tangent::tool().into(), + normal::tool().into(), coincident_tool::tool().into(), distance_tool::tool().into(), angle_tool::tool().into(), diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dd29b8322..033dcdced 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -121,7 +121,20 @@ impl<'a> GroupBuilder<'a> { /// (well: `BoundedLine`, §4 — nothing we draw is an infinite /// construction line), `Circle`, or `Arc` node the first time this /// entity is referenced. Returns `None` for an entity kind this pass - /// doesn't support as constraint geometry. + /// doesn't support as constraint geometry — currently including + /// `Ellipse` (constraint-parity Phase 5): the solver + /// (`sketch_solve.rs`) and this app's own XRecord format both support + /// ellipse-referencing constraints already, but `acadrust`'s + /// `AssocConstraintNodeData::Ellipse`/`BoundedEllipse` variants have a + /// different field shape from `Circle`/`Arc`'s here (`owner_id`/ + /// `is_implied`/`is_active` instead of `geometry_dependency`/ + /// `geometry_node_id`, i.e. they don't obviously look like the same + /// "geometry dependency for an entity reference" role) — without + /// confirming that against a real AutoCAD-written file, guessing would + /// risk writing a wrong-but-plausible-looking object graph, which is + /// worse than the honest "not persisted to DWG yet" gap this already + /// degrades to (same contract as `ArcLength`, which has no native + /// representation at all). fn geometry_node(&mut self, handle: Handle) -> Option { if let Some(existing) = self.entities.get(&handle) { if existing.geometry_node_id != 0 { @@ -269,6 +282,10 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), + // AutoCAD's own `GeomConstraintType` enum (ObjectARX's + // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` + // as a distinct, real geometric-constraint kind. + ConstraintKind::Normal => Some("ACNORMALCONSTRAINT"), ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"), ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"), ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"), @@ -285,15 +302,29 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { // (design doc §5) needs the resolved entity types, not just the // ConstraintKind, so it's handled by the caller instead of here. ConstraintKind::Equal | ConstraintKind::Parallel | ConstraintKind::Distance - | ConstraintKind::Angle | ConstraintKind::Radius => None, + | ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter + | ConstraintKind::DistanceX | ConstraintKind::DistanceY => None, + // No native DWG representation exists at all: neither + // `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no + // ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData` + // has anything for it. `constraint_node` below falls through to + // its own `_ => None` for this kind, same "can't persist to DWG, + // XRecord-only" gap the dependency-chain DXF omission already + // documents. + ConstraintKind::ArcLength => None, } } +/// `Diameter`/`DistanceX`/`DistanceY` reuse `Radius`'/`Distance`'s own DWG +/// class — real AutoCAD represents them as the same +/// `AcRadiusDiameterConstraint`/`AcDistanceConstraint` object with a +/// different `RadiusDiameterConstrType`/`DirectionType` mode byte (set in +/// `constraint_node` below), not a distinct native class. const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { match kind { - ConstraintKind::Distance => "ACDISTANCECONSTRAINT", + ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => "ACDISTANCECONSTRAINT", ConstraintKind::Angle => "ACANGLECONSTRAINT", - ConstraintKind::Radius => "ACRADIUSDIAMETERCONSTRAINT", + ConstraintKind::Radius | ConstraintKind::Diameter => "ACRADIUSDIAMETERCONSTRAINT", _ => "", } } @@ -361,9 +392,10 @@ fn constraint_node( builder.connect(node_id, nb); Some(node_id) } - ConstraintKind::Distance | ConstraintKind::Angle | ConstraintKind::Radius => { + ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY + | ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter => { let target_refs: Vec = match constraint.kind { - ConstraintKind::Radius => vec![builder.ref_node(*refs.first()?)?], + ConstraintKind::Radius | ConstraintKind::Diameter => vec![builder.ref_node(*refs.first()?)?], _ => { let [a, b] = refs else { return None }; vec![builder.ref_node(*a)?, builder.ref_node(*b)?] @@ -372,6 +404,7 @@ fn constraint_node( let owner_id = *target_refs.first()?; let node_id = builder.alloc_node_id(); let data = match constraint.kind { + // Plain two-point distance: undirected (`kNotDirected`). ConstraintKind::Distance => AssocConstraintNodeData::Distance { owner_id, is_implied: false, @@ -381,6 +414,28 @@ fn constraint_node( direction_type: 0, distance: None, }, + // X-only/Y-only: `kFixedDirection` with the fixed unit + // vector the distance is measured along — AutoCAD's own + // representation of DistanceX/DistanceY, not a distinct + // constraint class. + ConstraintKind::DistanceX => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 1, + distance: Some(Vector3::new(1.0, 0.0, 0.0)), + }, + ConstraintKind::DistanceY => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 1, + distance: Some(Vector3::new(0.0, 1.0, 0.0)), + }, ConstraintKind::Angle => AssocConstraintNodeData::Angle { owner_id, is_implied: false, @@ -397,6 +452,16 @@ fn constraint_node( dimension_dependency: Handle::NULL, mode: 0, }, + // `kCircleDiameter` — same class as Radius (`mode: 0` / + // `kCircleRadius`), different mode byte. + ConstraintKind::Diameter => AssocConstraintNodeData::RadiusDiameter { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + mode: 1, + }, _ => unreachable!(), }; builder.push_node(node_id, dimensional_class_name(constraint.kind), data); @@ -847,7 +912,7 @@ impl Scene { mod tests { use super::super::sketch_constraints::SketchScope; use super::*; - use acadrust::entities::{Circle, Line}; + use acadrust::entities::{Arc, Circle, Line}; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -860,6 +925,15 @@ mod tests { scene.add_entity(EntityType::Circle(Circle::from_center_radius(Vector3::new(center.0, center.1, 0.0), radius))) } + fn arc_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { + scene.add_entity(EntityType::Arc(Arc::from_center_radius_angles( + Vector3::new(center.0, center.1, 0.0), + radius, + 0.0, + std::f64::consts::PI, + ))) + } + /// Digs a scope's materialized native graph back out of `document`: /// `owner`'s extension dictionary -> `ACAD_ASSOCNETWORK` entry -> /// its one owned action, which (for this module) is always the @@ -1133,7 +1207,7 @@ mod tests { ); } - /// The 8 new `ConstraintKind`s all reuse the same generic + /// These `ConstraintKind`s all reuse the same generic /// `Geometrical`-shaped node path `geometrical_class_name` drives (see /// its own doc comment) — one representative mix of ref shapes (2 whole /// entities, an asymmetric point+whole pair, and a 3-ref case) is @@ -1167,6 +1241,7 @@ mod tests { vec![SketchRef::center(circle_a), SketchRef::center(circle_b), SketchRef::whole(axis)], None, ); + set.add(ConstraintKind::Normal, vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], None); scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; @@ -1178,6 +1253,7 @@ mod tests { let group = native_group(&reloaded, owner); let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + for expected in [ "ACCONCENTRICCONSTRAINT", "ACCOLINEARCONSTRAINT", @@ -1187,9 +1263,258 @@ mod tests { "ACPOINTCURVECONSTRAINT", "ACEQUALDISTANCECONSTRAINT", "ACSYMMETRICCONSTRAINT", + "ACNORMALCONSTRAINT", ] { assert!(class_names.contains(&expected), "{ext}: missing {expected} node, got {class_names:?}"); } } } + + /// Constraint-parity Phase 1: an Arc registers in the solver as its own + /// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and + /// this module already had `ACCONSTRAINEDARC` geometry-dependency + /// support in place before that. This confirms the two sides actually + /// meet: a Concentric/Tangent/Radius constraint referencing an arc + /// still gets its expected class names AND its geometry node is the + /// arc-specific one, through a real DWG/DXF byte round trip — not just + /// the in-memory graph this test module builds directly. + #[test] + fn constraints_referencing_an_arc_round_trip_with_the_arc_geometry_node() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Concentric, vec![SketchRef::center(arc), SketchRef::center(circle)], None); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(crate::scene::named_parameters::DrivingValue::Literal(9.0)), + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("arc_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!(class_names.contains(&"ACCONCENTRICCONSTRAINT"), "{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}"); + assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}"); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"), + "{ext}: the arc should have its own geometry node, got {class_names:?}" + ); + } + } + + /// Constraint-parity Phase 4b: a Coincident constraint referencing an + /// arc's actual *endpoint* (marker 0, not its center via `-3` or its + /// whole curve) round-trips correctly too — `SketchRef`'s marker is + /// serialized generically regardless of what entity type or point it + /// addresses, so this needs no dedicated DWG-side wiring beyond what + /// already existed; this test is here to actually confirm that rather + /// than assume it. + #[test] + fn a_coincident_constraint_on_an_arcs_endpoint_round_trips() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0)); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = + crate::io::load_bytes(&format!("arc_endpoint_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!(class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), "{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}"); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"), + "{ext}: the arc should still have its own geometry node, got {class_names:?}" + ); + } + } + + /// Constraint-parity Phase 5: an ellipse isn't a supported geometry + /// node type in `geometry_node` yet (see its own doc comment for why), + /// so a Concentric constraint referencing one degrades to "not in the + /// native graph" — the same "skip, don't error" contract every other + /// unbuildable native-constraint case gets — while the other + /// constraint in the same scope (Fixed, on a plain circle) still + /// persists normally, and the ellipse constraint itself still survives + /// in this app's own XRecord format. + #[test] + fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let ellipse = scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + 0.5, + ))); + let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Concentric, vec![SketchRef::center(ellipse), SketchRef::center(circle)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None); + + scene.materialize_sketch_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded_doc = + crate::io::load_bytes(&format!("ellipse_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded_doc, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!(class_names.contains(&"ACFIXEDCONSTRAINT"), "{ext}: the other constraint should still persist, got {class_names:?}"); + assert!( + !class_names.contains(&"ACCONCENTRICCONSTRAINT"), + "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" + ); + + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = reloaded_doc; + reloaded_scene.load_sketch_constraints_from_document(); + let restored = reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip")); + let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&ConstraintKind::Concentric), "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}"); + } + } + + /// Constraint-parity Phase 4a: `ArcLength` has no ObjectARX class at + /// all (confirmed against `AcExplicitConstr.h`) — this asserts the + /// degradation is exactly as documented: the native DWG/DXF graph + /// gets every *other* constraint on the arc (Radius, here) but no + /// ArcLength-shaped node, while the app's own constraint set (what + /// XRecord persistence actually serializes, per `sketch_persist.rs`'s + /// fully-generic encode/decode) still carries it untouched. + #[test] + fn arc_length_has_no_native_class_but_survives_in_the_apps_own_constraint_set() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(4.0))); + set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0))); + + // Both materializers, "called alongside, not instead of" each + // other (`materialize_dwg_native_constraints_for_save`'s own + // doc comment) — the native graph and this app's own XRecord + // format are independent, additive persistence paths. + scene.materialize_sketch_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded_doc = + crate::io::load_bytes(&format!("arc_length.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded_doc, owner); + let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}"); + assert!( + !class_names.iter().any(|n| n.contains("ARCLENGTH")), + "{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}" + ); + + // Still present in this app's own (XRecord-backed) constraint + // set — reload into a fresh `Scene` and read it back the same + // way `sketch_persist.rs`'s own round-trip tests do. + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = reloaded_doc; + reloaded_scene.load_sketch_constraints_from_document(); + let restored = reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip")); + let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&ConstraintKind::ArcLength), "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}"); + } + } + + /// Constraint-parity Phase 2: `Diameter`/`DistanceX`/`DistanceY` reuse + /// `Radius`'/`Distance`'s own DWG class with a different + /// `RadiusDiameterConstrType`/`DirectionType` mode byte, matching real + /// AutoCAD's representation (this module's own research, folded into + /// `dimensional_class_name`'s doc comment). Confirms that byte actually + /// survives a real DWG/DXF write+read, not just the in-memory node this + /// module built. + #[test] + fn diameter_and_directed_distance_constraints_round_trip_with_their_mode_byte() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let circle = circle_entity(&mut scene, (0.0, 0.0), 3.0); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Diameter, vec![SketchRef::whole(circle)], Some(DrivingValue::Literal(16.0))); + set.add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); + set.add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(3.0)), + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = + crate::io::load_bytes(&format!("diameter_directed.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let radius_diameter_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|n| match &n.data { + AssocConstraintNodeData::RadiusDiameter { mode, .. } => Some(*mode), + _ => None, + }) + .collect(); + assert_eq!(radius_diameter_nodes, vec![1], "{ext}: Diameter should round-trip as mode=1 (kCircleDiameter), got {radius_diameter_nodes:?}"); + + let distance_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|n| match &n.data { + AssocConstraintNodeData::Distance { direction_type, distance, .. } => Some((*direction_type, *distance)), + _ => None, + }) + .collect(); + assert_eq!(distance_nodes.len(), 2, "{ext}: expected DistanceX and DistanceY nodes, got {distance_nodes:?}"); + assert!( + distance_nodes.contains(&(1, Some(Vector3::new(1.0, 0.0, 0.0)))), + "{ext}: DistanceX should round-trip as direction_type=1 with a (1,0,0) direction, got {distance_nodes:?}" + ); + assert!( + distance_nodes.contains(&(1, Some(Vector3::new(0.0, 1.0, 0.0)))), + "{ext}: DistanceY should round-trip as direction_type=1 with a (0,1,0) direction, got {distance_nodes:?}" + ); + } + } } diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index 2700a3ea0..2623fdcb6 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -114,6 +114,42 @@ pub enum ConstraintKind { /// point-symmetry about a point, and symmetry between two lines, aren't /// modeled. Symmetric, + /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: + /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` + /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the + /// `RadiusDiameterConstrType` mode byte + /// (`dwg_native_constraints.rs`), matching how real AutoCAD represents + /// it — not a distinct native object type. + Diameter, + /// The X-only (resp. Y-only) component of the distance between two + /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG + /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its + /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction, again + /// matching AutoCAD's own representation rather than inventing a new + /// class. + DistanceX, + DistanceY, + /// A line perpendicular to a circle/arc's tangent at their point of + /// contact — `refs`: `[whole(a), whole(b)]`, either order. For the + /// Line/Circle-only entity model this is equivalent to "the line + /// passes through the circle's center" (a circle's radius is always + /// normal to its own tangent), so it solves via the same `PointOnLine` + /// primitive `PointOnCurve` already uses. Distinct from + /// `Perpendicular` (line-to-line only) — AutoCAD's own + /// `GeomConstraintType` enum lists `kNormal` and `kPerpendicular` + /// separately for exactly this reason. Line-Line has no meaning here + /// (that's plain `Perpendicular`) and isn't buildable. + Normal, + /// An arc's arc length (`radius * sweep angle`) — `refs`: + /// `[whole(arc)]`. Solves against the arc's own `start_angle`/ + /// `end_angle`, registered separately from its center/radius + /// (`sketch_solve.rs`'s `arc_angles` cache) — no native DWG + /// representation exists for this (neither `AcExplicitConstr.h` nor + /// `acadrust`'s `AssocConstraintNodeData` has an ArcLength-shaped + /// class/variant), so it persists in this app's own XRecord format + /// only, the same "DWG can't carry everything" gap the dependency-chain + /// DXF-omission precedent already documents. + ArcLength, } pub type ConstraintId = u32; @@ -388,6 +424,11 @@ impl ConstraintKind { ConstraintKind::PointOnCurve => "∈", ConstraintKind::EqualDistance => "≐", ConstraintKind::Symmetric => "S", + ConstraintKind::Diameter => "⌀", + ConstraintKind::DistanceX => "↔ₓ", + ConstraintKind::DistanceY => "↔ᵧ", + ConstraintKind::Normal => "⊾", + ConstraintKind::ArcLength => "⌢", } } } @@ -494,6 +535,40 @@ impl super::Scene { let changes: Vec<(Handle, super::ChangeKind)> = touched.into_iter().map(|h| (h, super::ChangeKind::Modified)).collect(); self.bump_entities(&changes); } + + /// Every persistent constraint, in any scope, currently driven by the + /// named parameter `name` — what the Named Parameters panel's "used by" + /// column shows. `entities` is the constraint's own referenced handles + /// (deduplicated; a two-point constraint on the same entity's own two + /// markers would otherwise list it twice), not resolved against the + /// live document — a caller wanting an entity's current type/position + /// still needs `Scene::document.get_entity`. + pub fn parameter_usage(&self, name: &str) -> Vec { + let mut out = Vec::new(); + for set in &self.sketch_constraints { + for c in &set.constraints { + let Some(DrivingValue::Named(n)) = &c.driving_param else { continue }; + if n != name { + continue; + } + let mut entities: Vec = c.refs.iter().map(|r| r.entity).collect(); + entities.sort(); + entities.dedup(); + out.push(ParameterUsage { scope: set.scope, constraint_id: c.id, kind: c.kind, entities }); + } + } + out + } +} + +/// One persistent constraint driven by a named parameter — [`Scene::parameter_usage`]'s +/// result type. +#[derive(Debug, Clone)] +pub struct ParameterUsage { + pub scope: SketchScope, + pub constraint_id: ConstraintId, + pub kind: ConstraintKind, + pub entities: Vec, } #[cfg(test)] @@ -586,4 +661,68 @@ mod tests { assert_eq!(restored.constraints[1].driving_param, Some(DrivingValue::Literal(12.5))); assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); } + + #[test] + fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { + let mut scene = super::super::Scene::new(); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("gap".to_string())), + ); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(2))], + Some(DrivingValue::Named("gap".to_string())), + ); + // Unrelated: a literal-driven constraint and one driven by a + // different name must not show up. + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(3))], + Some(DrivingValue::Literal(5.0)), + ); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], + Some(DrivingValue::Named("other".to_string())), + ); + + let usage = scene.parameter_usage("gap"); + assert_eq!(usage.len(), 2, "exactly the two constraints driven by 'gap', got {usage:?}"); + assert!(usage.iter().any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); + assert!(usage.iter().any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); + + assert_eq!(scene.parameter_usage("nonexistent").len(), 0); + } + + #[test] + fn parameter_usage_searches_every_scope_not_just_model_space() { + let mut scene = super::super::Scene::new(); + let block = h(99); + scene.sketch_constraint_set_mut(SketchScope::Block(block)).add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(1))], + Some(DrivingValue::Named("r".to_string())), + ); + let usage = scene.parameter_usage("r"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].scope, SketchScope::Block(block)); + } + + #[test] + fn parameter_usage_deduplicates_an_entity_referenced_by_two_markers() { + let mut scene = super::super::Scene::new(); + // A Distance constraint whose two points are both on the same + // entity (e.g. a line's own start and end) must list that entity + // once, not twice. + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("len".to_string())), + ); + let usage = scene.parameter_usage("len"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].entities, vec![h(1)]); + } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index e88519b50..3959c1db1 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -13,14 +13,43 @@ //! Maps every [`ConstraintKind`]. `Tangent` covers Line-Circle //! (`C2LDistance` with a driven zero-distance target aliased to the //! circle's own radius via `internal: false`) and Circle-Circle -//! (`TangentCircumf`) — Line-Line has no meaning and Arc isn't a supported -//! [`EntityGeom`] yet (matches that type's own documented scope), so those -//! ref shapes build nothing, same "skip, don't panic" contract every other -//! unbuildable constraint already gets. `ccw`/`internal` are picked from -//! the pair's *current* geometry (which side of the line the circle already -//! sits on; whether the circles are already nested) so the very first solve -//! doesn't have to cross a sign-flip singularity to reach the nearest valid +//! (`TangentCircumf`) — Line-Line has no meaning, so that ref shape builds +//! nothing, same "skip, don't panic" contract every other unbuildable +//! constraint already gets. `ccw`/`internal` are picked from the pair's +//! *current* geometry (which side of the line the circle already sits on; +//! whether the circles are already nested) so the very first solve doesn't +//! have to cross a sign-flip singularity to reach the nearest valid //! tangent configuration. +//! +//! An `Arc` registers as a full [`EntityGeom::Arc`]: center, radius, +//! `start_angle`/`end_angle`, *and* real `start`/`end` points (marker `0`/ +//! `1`, matching `Line`'s own convention — see `sketch_constraints:: +//! resolve_point`'s read-side counterpart). Naively, `start`/`end` would be +//! independent free params that drift away from "actually on the circle at +//! that angle" the moment anything pulls on them — planegcs itself avoids +//! that by adding "arc rules" constraints internally whenever an arc is +//! created; `ocs_gcs` has no dedicated helper for that, but it turns out not +//! to need one: `ocs_gcs::constraints::curve_generic::CurveValue` already +//! ties a point's `x`/`y` to *any* `Curve`'s value at a parameter, and +//! `geo::Arc` already implements `Curve` (its `value(u)` is exactly +//! "point on the circle at angle `u`"). So `solve_scope` adds four +//! `CurveValue` constraints per registered arc — `start.x`/`start.y` tied +//! to the curve at `start_angle`, `end.x`/`end.y` tied to it at +//! `end_angle` — right after building every `SketchConstraint`'s own +//! system-level constraints, unconditionally, the same way planegcs's own +//! arc rules apply regardless of which constraints actually reference the +//! arc. That's what makes Coincident/PointOnCurve/Midpoint/EqualDistance/ +//! Symmetric on an arc's actual endpoint (as opposed to its whole curve or +//! its center) work: they resolve through the ordinary `point_ref`/ +//! `point_for_marker` machinery once markers `0`/`1` return real, +//! consistency-anchored points, no per-kind special-casing needed. +//! +//! Every whole-circle-shaped kind (Concentric, Tangent, Equal, CenterPoint, +//! Radius, Diameter, Fixed, Normal) still works on an arc via its +//! `.circle` field — `whole_circle` extracts it generically; `Tangent`/ +//! `Normal`'s own inline matches (which don't go through `whole_circle`) +//! have explicit `EntityGeom::Arc` arms alongside their `Circle` ones for +//! the same reason. use std::collections::HashMap; use std::rc::Rc; @@ -29,13 +58,14 @@ use acadrust::entities::EntityType; use acadrust::types::Handle; use ocs_gcs::constraints::angle_distance::L2LAngle; -use ocs_gcs::constraints::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; +use ocs_gcs::constraints::circle_arc::{ArcLength, C2LDistance, P2CDistance, TangentCircumf}; +use ocs_gcs::constraints::curve_generic::CurveValue; use ocs_gcs::constraints::point_line::{ - CenterOfGravity, Equal, EqualLineLength, MidpointOnLine, Parallel as ParallelConstraint, + CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, P2PDistance, }; use ocs_gcs::constraints::Constraint; -use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint}; +use ocs_gcs::geo::{Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint}; use ocs_gcs::solvers::dogleg::solve_dl; use ocs_gcs::system::System; @@ -44,14 +74,22 @@ use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, use super::{ChangeKind, Scene}; /// One referenced entity's geometry, registered into an `ocs_gcs::System`'s -/// parameter store. Only the two entity types the existing one-shot -/// constraint commands already support (`crate::modules::draw::constrain`) -/// — extending this to arcs/ellipses is future work, not a gap introduced -/// here. +/// parameter store. #[derive(Clone, Copy)] enum EntityGeom { Line(GLine), Circle(GCircle), + /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ + /// `end_angle`, and real `start`/`end` points kept consistent with + /// those by the arc-rules `CurveValue` constraints `solve_scope` adds + /// for every registered arc. See this module's doc comment. + Arc(GArc), + /// Center-and-shape support only — see `register_entity`'s `Ellipse` + /// arm for the acadrust-to-`ocs_gcs` parametrization conversion, and + /// this module's doc comment for what isn't wired up yet (major/minor + /// axis dimensional constraints, `PointOnEllipse`-based Coincident/ + /// Tangent). + Ellipse(GEllipse), } impl EntityGeom { @@ -64,11 +102,34 @@ impl EntityGeom { (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), (EntityGeom::Circle(c), -3) => Some(c.center), + (EntityGeom::Arc(a), 0) => Some(a.start), + (EntityGeom::Arc(a), 1) => Some(a.end), + (EntityGeom::Arc(a), -3) => Some(a.circle.center), + (EntityGeom::Ellipse(e), -3) => Some(e.center), _ => None, } } } +/// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or +/// "a whole line" — an `Arc` collapses to its `.circle` here exactly like +/// `whole_circle` does, so those two constraints' own inline matches don't +/// need every Circle/Arc combination spelled out separately. +enum CircleOrLine { + Circle(GCircle), + Line(GLine), + Other, +} + +fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { + match g { + EntityGeom::Circle(c) => CircleOrLine::Circle(c), + EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), + EntityGeom::Line(l) => CircleOrLine::Line(l), + EntityGeom::Ellipse(_) => CircleOrLine::Other, + } +} + /// Reads `handle`'s live geometry and registers it as fresh, free (never /// `driven`) parameters in `sys` — every referenced entity is solved for, /// nothing is a priori fixed; per planegcs-style solvers, an under- @@ -91,6 +152,44 @@ fn register_entity(document: &acadrust::CadDocument, sys: &mut System, handle: H let rad = sys.add_param(c.radius, false); Some(EntityGeom::Circle(GCircle { center, rad })) } + EntityType::Arc(a) => { + // Full registration: center/radius, both angles, and real + // start/end points seeded from acadrust's own `start_point`/ + // `end_point` (already consistent with center/radius/angle at + // registration time) — `solve_scope` adds the arc-rules + // constraints that keep them that way under solving. See this + // module's doc comment. + let center = GPoint::new(sys.add_param(a.center.x, false), sys.add_param(a.center.y, false)); + let rad = sys.add_param(a.radius, false); + let start_angle = sys.add_param(a.start_angle, false); + let end_angle = sys.add_param(a.end_angle, false); + let start_seed = a.start_point(); + let end_seed = a.end_point(); + let start = GPoint::new(sys.add_param(start_seed.x, false), sys.add_param(start_seed.y, false)); + let end = GPoint::new(sys.add_param(end_seed.x, false), sys.add_param(end_seed.y, false)); + Some(EntityGeom::Arc(GArc { circle: GCircle { center, rad }, start, end, start_angle, end_angle })) + } + EntityType::Ellipse(el) => { + // acadrust's `Ellipse` is center + major-axis vector (its length + // is the major radius) + minor/major ratio; `ocs_gcs::geo:: + // Ellipse` is center + one focus + minor radius. Converting: + // minor_radius = major_radius * ratio, then the focus distance + // c follows from a² = b² + c² (standard ellipse identity), and + // focus1 sits `c` along the major-axis direction from center. + let major_radius = el.major_axis.length(); + let minor_radius = major_radius * el.minor_axis_ratio; + let focus_dist = (major_radius * major_radius - minor_radius * minor_radius).max(0.0).sqrt(); + // Direction is meaningless once major_radius is ~0 (a + // degenerate point-ellipse) — arbitrarily fall back to +X + // rather than dividing by ~0 in `normalize`. + let unit_major = + if major_radius > 1e-9 { el.major_axis.normalize() } else { acadrust::types::Vector3::UNIT_X }; + let focus1_point = el.center + unit_major * focus_dist; + let center = GPoint::new(sys.add_param(el.center.x, false), sys.add_param(el.center.y, false)); + let focus1 = GPoint::new(sys.add_param(focus1_point.x, false), sys.add_param(focus1_point.y, false)); + let radmin = sys.add_param(minor_radius, false); + Some(EntityGeom::Ellipse(GEllipse { center, focus1, radmin })) + } _ => None, } } @@ -142,11 +241,12 @@ fn build_constraint( let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? { EntityGeom::Line(l) => Some(l), - EntityGeom::Circle(_) => None, + EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => None, }; let whole_circle = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? { EntityGeom::Circle(circ) => Some(circ), - EntityGeom::Line(_) => None, + EntityGeom::Arc(a) => Some(a.circle), + EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, }; let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { let marker = r.marker?; @@ -232,6 +332,18 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(circ, p, zero))] } + // Same "distance to the underlying circle is zero" relation + // as the `Circle` arm — doesn't restrict the point to + // within the arc's own sweep, matching that same + // pre-existing simplification for `Circle`. + EntityGeom::Arc(a) => { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new(a.circle, p, zero))] + } + // `PointOnEllipse` (`ocs_gcs::constraints::conic`) isn't + // wired up yet — deliberately deferred alongside + // ellipse-tangency, same as this module's doc comment. + EntityGeom::Ellipse(_) => Vec::new(), } } ConstraintKind::Symmetric => { @@ -276,6 +388,43 @@ fn build_constraint( Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), ] } + // Pinning the 5 intrinsic params (center, radius, both + // angles) is enough — `start`/`end` are already tied to + // those via the arc-rules `CurveValue` constraints + // `solve_scope` adds for every arc, so pinning them too + // would just be redundant. + EntityGeom::Arc(a) => { + let (cx, cy, r, sa, ea) = { + let store = sys.store(); + ( + store.get(a.circle.center.x), + store.get(a.circle.center.y), + store.get(a.circle.rad), + store.get(a.start_angle), + store.get(a.end_angle), + ) + }; + vec![ + Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), + Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), + Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), + ] + } + EntityGeom::Ellipse(el) => { + let (cx, cy, fx, fy, b) = { + let store = sys.store(); + (store.get(el.center.x), store.get(el.center.y), store.get(el.focus1.x), store.get(el.focus1.y), store.get(el.radmin)) + }; + vec![ + Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(el.focus1.x, sys.add_param(fx, true), 1.0)), + Rc::new(Equal::new(el.focus1.y, sys.add_param(fy, true), 1.0)), + Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), + ] + } } } ConstraintKind::Distance => { @@ -301,13 +450,50 @@ fn build_constraint( let target = sys.add_param(resolved, true); vec![Rc::new(Equal::new(circle.rad, target, 1.0))] } + // Same math as `Radius`, just `radius = target / 2` instead of + // `radius = target` — `Equal`'s `ratio` param already supports + // this, matching how AutoCAD itself stores Diameter as the same + // `ACRADIUSDIAMETERCONSTRAINT` class with a different mode byte + // rather than a distinct one (`dwg_native_constraints.rs`). + ConstraintKind::Diameter => { + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(circle) = whole_circle(sys, cache, *r) else { + return Vec::new(); + }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![Rc::new(Equal::new(circle.rad, target, 0.5))] + } + // Signed X-only/Y-only component of the distance between two + // points — `Difference::new(p1, p2, d)` is `p2 - p1 == d`, matching + // AutoCAD's own `ACDISTANCECONSTRAINT` with a fixed-direction + // vector rather than a distinct native class + // (`dwg_native_constraints.rs`). + ConstraintKind::DistanceX => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![Rc::new(Difference::new(pa.x, pb.x, target))] + } + ConstraintKind::DistanceY => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![Rc::new(Difference::new(pa.y, pb.y, target))] + } ConstraintKind::Tangent => { let [a, b] = c.refs.as_slice() else { return Vec::new() }; let (Some(ga), Some(gb)) = (resolve_ref(document, sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { return Vec::new(); }; - match (ga, gb) { - (EntityGeom::Circle(c1), EntityGeom::Circle(c2)) => { + // An `Arc`'s `.circle` behaves identically to a plain `Circle` + // for tangency math — normalize both refs down first so the + // match below doesn't need every Circle/Arc combination + // written out separately. + match (as_circle_or_line(ga), as_circle_or_line(gb)) { + (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { let store = sys.store(); let (x1, y1, r1) = (store.get(c1.center.x), store.get(c1.center.y), store.get(c1.rad)); let (x2, y2, r2) = (store.get(c2.center.x), store.get(c2.center.y), store.get(c2.rad)); @@ -319,7 +505,7 @@ fn build_constraint( let internal = center_dist < (r1 - r2).abs(); vec![Rc::new(TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, internal))] } - (EntityGeom::Circle(circ), EntityGeom::Line(line)) | (EntityGeom::Line(line), EntityGeom::Circle(circ)) => { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { let store = sys.store(); let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); @@ -336,11 +522,38 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(C2LDistance::new(circ, line, zero, ccw, false))] } - // Line-Line tangency has no meaning; Arc isn't a supported - // `EntityGeom` yet (see that type's own doc comment). + // Line-Line tangency has no meaning; either side being an + // Ellipse falls here too (`PointOnEllipse`/tangency isn't + // wired up yet). _ => Vec::new(), } } + // A circle's radius is always normal to its own tangent, so + // "line normal to circle/arc" reduces to "line passes through the + // circle's center" — the same `PointOnLine` primitive + // `PointOnCurve` already uses for a point-on-line case. + ConstraintKind::Normal => { + let [a, b] = c.refs.as_slice() else { return Vec::new() }; + let (Some(ga), Some(gb)) = (resolve_ref(document, sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + return Vec::new(); + }; + match (as_circle_or_line(ga), as_circle_or_line(gb)) { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + vec![Rc::new(PointOnLine::new(circ.center, line))] + } + // Line-Line has no meaning here (that's `Perpendicular`); + // Circle-Circle "normal" (orthogonal circles) needs a + // different, not-yet-implemented relation. + _ => Vec::new(), + } + } + ConstraintKind::ArcLength => { + let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(EntityGeom::Arc(arc)) = resolve_ref(document, sys, cache, *r) else { return Vec::new() }; + let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let target = sys.add_param(resolved, true); + vec![Rc::new(ArcLength::new(arc, target))] + } } } @@ -386,6 +599,22 @@ fn solve_scope( return None; } + // Arc rules (this module's doc comment): keep every registered arc's + // `start`/`end` points consistent with its center/radius/angle, + // unconditionally — planegcs itself adds these the moment an arc + // exists, not only when some `SketchConstraint` happens to reference + // its endpoint. Not tracked in `owner`: these are solver-internal + // bookkeeping, never redundant with anything a user-facing constraint + // could name, so there's no `ConstraintId` for them to report against. + for geom in cache.values() { + let EntityGeom::Arc(arc) = geom else { continue }; + let curve = Rc::new(*arc); + sys.add_constraint(Rc::new(CurveValue::new(arc.start, arc.start.x, curve.clone(), arc.start_angle))); + sys.add_constraint(Rc::new(CurveValue::new(arc.start, arc.start.y, curve.clone(), arc.start_angle))); + sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.x, curve.clone(), arc.end_angle))); + sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.y, curve, arc.end_angle))); + } + let partitions = sys.partition(); for sub in &partitions { solve_dl(sub, sys.store_mut()); @@ -404,6 +633,15 @@ fn solve_scope( .map(|g| match g { EntityGeom::Line(_) => 4, EntityGeom::Circle(_) => 3, + // Raw param count (center×2, rad, start×2, end×2, both + // angles) — same "raw, not netted against its own + // constraints" convention as every other arm here. The four + // arc-rules `CurveValue` constraints (always present, added + // just above) already reduce this to 5 *effective* DOF through + // the normal `touched_free`/`diag.dof` accounting below, the + // same way any other constraint would. + EntityGeom::Arc(_) => 9, + EntityGeom::Ellipse(_) => 5, }) .sum(); let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); @@ -458,6 +696,65 @@ fn solve_scope( results.push((handle, EntityType::Circle(updated))); } } + (EntityType::Arc(a), EntityGeom::Arc(g)) => { + // Center/radius/angles only — `start`/`end` are derived + // (acadrust's `Arc` has no separate stored fields for them; + // `start_point()`/`end_point()` compute them from these + // same four), so nothing further needs writing back for + // them specifically. + let (cx, cy, r) = (store.get(g.circle.center.x), store.get(g.circle.center.y), store.get(g.circle.rad)); + let (new_start_angle, new_end_angle) = (store.get(g.start_angle), store.get(g.end_angle)); + if (cx - a.center.x).abs() > MOVE_EPS + || (cy - a.center.y).abs() > MOVE_EPS + || (r - a.radius).abs() > MOVE_EPS + || (new_start_angle - a.start_angle).abs() > MOVE_EPS + || (new_end_angle - a.end_angle).abs() > MOVE_EPS + { + let mut updated = a.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.radius = r; + updated.start_angle = new_start_angle; + updated.end_angle = new_end_angle; + results.push((handle, EntityType::Arc(updated))); + } + } + (EntityType::Ellipse(el), EntityGeom::Ellipse(g)) => { + // Converting back to acadrust's center + major-axis-vector + + // minor/major-ratio parametrization — the inverse of + // `register_entity`'s `Ellipse` arm. `rad_maj_at` (already + // provided by `ocs_gcs::geo::Ellipse`) does the a²=b²+c² + // algebra; only the major-axis *direction* needs deriving + // here, from the (possibly moved) focus relative to center. + let (cx, cy) = (store.get(g.center.x), store.get(g.center.y)); + let (fx, fy) = (store.get(g.focus1.x), store.get(g.focus1.y)); + let minor_radius = store.get(g.radmin); + let (major_radius, _) = g.rad_maj_at(store, None); + let focus_dist = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt(); + // Direction is ill-defined once the ellipse is (near) + // circular — keep whatever direction it already had rather + // than snapping to an arbitrary axis. + let unit_major = if focus_dist > 1e-9 { + acadrust::types::Vector3::new(fx - cx, fy - cy, 0.0).normalize() + } else { + el.major_axis.normalize() + }; + let new_major_axis = unit_major * major_radius; + let new_ratio = if major_radius > 1e-9 { minor_radius / major_radius } else { 0.0 }; + if (cx - el.center.x).abs() > MOVE_EPS + || (cy - el.center.y).abs() > MOVE_EPS + || (new_major_axis.x - el.major_axis.x).abs() > MOVE_EPS + || (new_major_axis.y - el.major_axis.y).abs() > MOVE_EPS + || (new_ratio - el.minor_axis_ratio).abs() > MOVE_EPS + { + let mut updated = el.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.major_axis = new_major_axis; + updated.minor_axis_ratio = new_ratio; + results.push((handle, EntityType::Ellipse(updated))); + } + } _ => {} } } diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index cbcb97667..bfe494f02 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -17,10 +17,76 @@ use crate::app::Message; use crate::scene::named_parameters::ParameterTable; +use crate::scene::Scene; use crate::t; -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use acadrust::types::Handle; +use acadrust::EntityType; +use iced::widget::tooltip::Position as TipPos; +use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space}; use iced::{Background, Element, Length, Theme}; +/// Short "Kind handle" label for one referenced entity — the same handle +/// display (`0x…`) AutoCAD/this app's own conventions use, since entities +/// have no user-facing name to show instead. +fn entity_label(scene: &Scene, handle: Handle) -> String { + match scene.document.get_entity(handle) { + Some(EntityType::Line(_)) => format!("Line {handle}"), + Some(EntityType::Circle(_)) => format!("Circle {handle}"), + Some(EntityType::Arc(_)) => format!("Arc {handle}"), + Some(_) => format!("Entity {handle}"), + None => format!("(erased {handle})"), + } +} + +/// One line per constraint currently driven by `name`, e.g. +/// `"Distance: Line 0x2A, Line 0x2B"` — empty when nothing references it +/// yet (a parameter defined but not yet used by any constraint). +fn usage_lines(scene: &Scene, name: &str) -> Vec { + scene + .parameter_usage(name) + .iter() + .map(|u| { + let entities = u.entities.iter().map(|h| entity_label(scene, *h)).collect::>().join(", "); + format!("{:?}: {entities}", u.kind) + }) + .collect() +} + +/// The "Used by" column's cell for one row: a compact kind-count summary +/// (e.g. `"Distance ×2, Radius ×1"`), with the full per-constraint entity +/// list (`usage_lines`) as a hover tooltip — the column is too narrow to +/// show entity lists inline once a parameter drives more than one or two +/// constraints. A blank name (a fresh, not-yet-named row) or a name driving +/// nothing shows a muted em dash instead. +fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> { + if name.is_empty() { + return Space::new().into(); + } + let lines = usage_lines(scene, name); + if lines.is_empty() { + return text("—").size(11).style(muted_style).into(); + } + let mut counts: Vec<(String, usize)> = Vec::new(); + for line in &lines { + let kind = line.split(':').next().unwrap_or(line).to_string(); + match counts.iter_mut().find(|(k, _)| *k == kind) { + Some((_, n)) => *n += 1, + None => counts.push((kind, 1)), + } + } + let summary = counts.iter().map(|(k, n)| format!("{k} ×{n}")).collect::>().join(", "); + let mut body = column![].spacing(2); + for line in &lines { + body = body.push(text(line.clone()).size(11)); + } + tooltip( + text(summary).size(11).style(muted_style), + container(body).style(container::bordered_box).padding([4, 8]), + TipPos::Top, + ) + .into() +} + /// Which column of a parameter row a text edit targets. #[derive(Clone, Copy, Debug)] pub enum ParamField { @@ -113,8 +179,12 @@ fn preview(rows: &[ParamEditorRow]) -> Vec>> { } /// Build the named-parameter editor content. `rows` is the live working -/// buffer. -pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { +/// buffer; `scene` supplies the "Used by" column, computed against the +/// *live*, already-applied constraint set (`Scene::parameter_usage`) — +/// unapplied edits to a row's name in the buffer don't retroactively +/// relabel what's shown, same as the rest of this editor only takes effect +/// on Apply. +pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { let title = text(t!("Named Parameters")).size(15); let hint = text(t!( "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." @@ -124,13 +194,15 @@ pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizin let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 }; const NAME_WIDTH: f32 = 120.0; - const VALUE_WIDTH: f32 = 140.0; + const VALUE_WIDTH: f32 = 100.0; + const USED_BY_WIDTH: f32 = 160.0; let head = container( row![ container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)), container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width), container(text(t!("Value")).size(11).style(muted_style)).width(Length::Fixed(VALUE_WIDTH)), + container(text(t!("Used by")).size(11).style(muted_style)).width(Length::Fixed(USED_BY_WIDTH)), Space::new().width(Length::Fixed(30.0)), ] .spacing(8), @@ -155,14 +227,21 @@ pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizin Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(), None => Space::new().into(), }; + let used_by_cell = used_by_cell(scene, row.name.trim()); let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) .on_press(Message::NamedParametersRemove(idx)) .padding([2, 6]) .style(button::danger); list = list.push( - row![name_box, formula_box, container(value_cell).width(Length::Fixed(VALUE_WIDTH)), del] - .spacing(8) - .align_y(iced::Center), + row![ + name_box, + formula_box, + container(value_cell).width(Length::Fixed(VALUE_WIDTH)), + container(used_by_cell).width(Length::Fixed(USED_BY_WIDTH)), + del + ] + .spacing(8) + .align_y(iced::Center), ); } @@ -255,4 +334,37 @@ mod tests { assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate"))); assert_eq!(results[2], Some(Ok(3.0)), "an unrelated row's name must not be affected"); } + + #[test] + fn usage_lines_names_the_constraint_kind_and_its_entities() { + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + let mut scene = Scene::new(); + let line = scene.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(crate::scene::named_parameters::DrivingValue::Named("gap".to_string())), + ); + + let lines = usage_lines(&scene, "gap"); + assert_eq!(lines.len(), 1); + assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}"); + assert!(lines[0].contains(&format!("{line}")), "should name the actual entity handle, got {lines:?}"); + } + + #[test] + fn usage_lines_is_empty_for_a_parameter_nothing_references() { + let scene = Scene::new(); + assert!(usage_lines(&scene, "unused").is_empty()); + } + + #[test] + fn entity_label_reports_erased_for_a_dangling_handle() { + let scene = Scene::new(); + let label = entity_label(&scene, acadrust::types::Handle::new(999)); + assert!(label.starts_with("(erased "), "got {label}"); + } } diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index d2838eb73..194bcd597 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -42,6 +42,44 @@ fn circle_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { } } +fn add_arc(scene: &mut Scene, cx: f64, cy: f64, radius: f64, start_angle: f64, end_angle: f64) -> Handle { + scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( + Vector3::new(cx, cy, 0.0), + radius, + start_angle, + end_angle, + ))) +} + +fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => (a.center, a.radius), + other => panic!("expected an Arc, got {other:?}"), + } +} + +fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => (a.radius, a.start_angle, a.end_angle), + other => panic!("expected an Arc, got {other:?}"), + } +} + +fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { + scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes( + Vector3::new(cx, cy, 0.0), + Vector3::new(major_axis.0, major_axis.1, 0.0), + ratio, + ))) +} + +fn ellipse_geom(scene: &Scene, handle: Handle) -> (Vector3, Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Ellipse(e) => (e.center, e.major_axis, e.minor_axis_ratio), + other => panic!("expected an Ellipse, got {other:?}"), + } +} + #[test] fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { let mut scene = Scene::new(); @@ -563,3 +601,391 @@ fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { "b's center should have been pulled to a's mirror image across the axis: expected (-3, 4), got {cb:?}" ); } + +// Phase 1 of docs' constraint-parity plan: an Arc registers in the solver +// as its own center/radius (an `EntityGeom::Circle`), so every whole-circle +// constraint kind — Radius, Tangent, Concentric among them — already works +// on it. These mirror the equivalent Circle tests above one-for-one. + +#[test] +fn radius_constraint_on_an_arc_solves_to_the_target() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(7.5)), + ); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (_, radius) = arc_geom(&scene, arc); + assert!((radius - 7.5).abs() < 1e-6, "arc radius should have solved to the driven target: got {radius}"); +} + +#[test] +fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Tangent, vec![SketchRef::whole(arc), SketchRef::whole(line)], None); + scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, radius) = arc_geom(&scene, arc); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!((signed_dist.abs() - radius).abs() < 1e-6, "center-to-line distance should equal the arc's radius after solving: dist={signed_dist} radius={radius}"); +} + +#[test] +fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 4.0, 0.0, std::f64::consts::PI); + let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(arc), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + + let (arc_center, _) = arc_geom(&scene, arc); + let (circle_center, _) = circle_geom(&scene, circle); + assert!( + (arc_center.x - circle_center.x).abs() < 1e-6 && (arc_center.y - circle_center.y).abs() < 1e-6, + "arc and circle centers should coincide after solving: arc={arc_center:?} circle={circle_center:?}" + ); +} + +// Phase 4b of the same plan: an arc's actual endpoints (marker 0/1, not +// just its center or whole curve) are now real, solvable points — kept +// consistent with center/radius/angle by the "arc rules" `CurveValue` +// constraints `solve_scope` adds for every registered arc. Coincident/ +// PointOnCurve/etc. touching an arc endpoint need no kind-specific code at +// all: they resolve through the same generic `point_ref`/`point_for_marker` +// path every other point-ref constraint already uses. + +fn arc_start_point(scene: &Scene, handle: Handle) -> Vector3 { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => a.start_point(), + other => panic!("expected an Arc, got {other:?}"), + } +} + +#[test] +fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); + let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); + + // Move the arc directly (bypassing the solver), same pattern as the + // equivalent Circle/Ellipse CenterPoint tests — the constraint is a + // symmetric point-equality, so what's checked is that both sides end + // up equal to *each other*, not at some hardcoded absolute position. + if let Some(EntityType::Arc(a)) = scene.document.get_entity_mut(arc) { + a.center = Vector3::new(-8.0, 3.0, 0.0); + } + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let arc_start = arc_start_point(&scene, arc); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (arc_start.x - line_start.x).abs() < 1e-6 && (arc_start.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the arc's start point: arc_start={arc_start:?} line_start={line_start:?}" + ); +} + +#[test] +fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solving() { + // The actual "arc rules" guarantee: after a solve that moved a + // constrained endpoint, recomputing the arc's start point from its + // (possibly also moved) center/radius/start_angle must land exactly on + // the same point the constraint pulled the endpoint to — not just "some + // point near it". This is what would drift if the `CurveValue` ties + // were missing or wrong. + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + set.add(ConstraintKind::Coincident, vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], None); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (center, radius, start_angle, _) = { + match scene.document.get_entity(arc).expect("entity exists") { + EntityType::Arc(a) => (a.center, a.radius, a.start_angle, a.end_angle), + other => panic!("expected an Arc, got {other:?}"), + } + }; + let recomputed_start = Vector3::new(center.x + radius * start_angle.cos(), center.y + radius * start_angle.sin(), 0.0); + let actual_start = arc_start_point(&scene, arc); + assert!( + (recomputed_start.x - actual_start.x).abs() < 1e-6 && (recomputed_start.y - actual_start.y).abs() < 1e-6, + "start_point() must match center+radius*angle exactly: recomputed={recomputed_start:?} actual={actual_start:?}" + ); + assert!( + (actual_start.x - 12.0).abs() < 1e-6 && (actual_start.y - 7.0).abs() < 1e-6, + "the constrained endpoint itself should have reached the fixed anchor: {actual_start:?}" + ); +} + +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, radius) = arc_geom(&scene, arc); + let (p, _) = line_endpoints(&scene, marker); + let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt(); + assert!((dist - radius).abs() < 1e-6, "point should land exactly on the arc's circle: dist={dist} radius={radius}"); +} + +#[test] +fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { + // Exercises both registration paths for the same arc in one solve: the + // whole-circle path (`Radius`, via `.circle`) and the endpoint path + // (`Coincident` on marker 0, via the arc-rules-anchored `start` point). + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); + let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + set.add(ConstraintKind::Coincident, vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], None); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0))); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (_, radius) = arc_geom(&scene, arc); + assert!((radius - 6.0).abs() < 1e-6, "Radius constraint should still hold: got {radius}"); + let actual_start = arc_start_point(&scene, arc); + assert!( + (actual_start.x - 9.0).abs() < 1e-6 && (actual_start.y - -4.0).abs() < 1e-6, + "the arc's start point should have reached the fixed anchor despite the simultaneous Radius constraint: {actual_start:?}" + ); +} + +// Phase 2 of the same plan: `Diameter`/`DistanceX`/`DistanceY` reuse +// existing `ocs_gcs` primitives (`Equal`'s `ratio`, `Difference`) with no +// new solver-crate code. + +#[test] +fn diameter_constraint_drives_the_radius_to_half_the_target() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Diameter, + vec![SketchRef::whole(circle)], + Some(DrivingValue::Literal(16.0)), + ); + scene.bump_entities(&[(circle, ChangeKind::Modified)]); + + let (_, radius) = circle_geom(&scene, circle); + assert!((radius - 8.0).abs() < 1e-6, "radius should be half the diameter target: got {radius}"); +} + +#[test] +fn distance_x_constraint_pins_only_the_x_component() { + let mut scene = Scene::new(); + // Deliberately not axis-aligned so DistanceX and DistanceY are + // distinguishable from a plain Distance/Horizontal outcome. + let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!((end.x - start.x - 10.0).abs() < 1e-6, "x component should equal the target: dx={}", end.x - start.x); +} + +// Phase 3 of the same plan: `Normal` reduces to "line passes through the +// circle's center" for the Line/Circle-only entity model, reusing +// `PointOnLine` (the same primitive `PointOnCurve` already uses). + +#[test] +fn normal_constraint_pulls_the_line_through_the_circles_center() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 5.0, 5.0, 2.0); + // A horizontal line well clear of the circle's center. + let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Normal, vec![SketchRef::whole(circle), SketchRef::whole(line)], None); + scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, _) = circle_geom(&scene, circle); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!(signed_dist.abs() < 1e-6, "the circle's center should lie on the (infinite) line after solving: dist={signed_dist}"); +} + +// Phase 4a of the same plan: `ArcLength` registers an arc's +// `start_angle`/`end_angle` separately from its center/radius (which still +// flow through the existing `EntityGeom::Circle` aliasing), so it can +// combine with a `Radius` constraint on the same arc in one solve. + +#[test] +fn arc_length_constraint_drives_the_swept_length_to_the_target() { + let mut scene = Scene::new(); + // radius 5, a 90° span (length ≈ 7.85) — deliberately not the target. + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(5.0))); + set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(10.0))); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (radius, start_angle, end_angle) = arc_angles(&scene, arc); + assert!((radius - 5.0).abs() < 1e-6, "Radius constraint should still hold: got {radius}"); + let length = radius * (end_angle - start_angle); + assert!((length - 10.0).abs() < 1e-5, "swept length should equal the ArcLength target: got {length}"); +} + +// Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- +// radius (converted from acadrust's center + major-axis-vector + ratio +// parametrization). Only center-based whole-entity constraints are wired +// up so far — Concentric, CenterPoint, Fixed — mirroring how Arc started +// at "center/radius only" in Phase 1. Axis-length constraints and +// `PointOnEllipse`-based Coincident/Tangent are a further follow-up. + +#[test] +fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); + let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(ellipse, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + + let (ellipse_center, _, _) = ellipse_geom(&scene, ellipse); + let (circle_center, _) = circle_geom(&scene, circle); + assert!( + (ellipse_center.x - circle_center.x).abs() < 1e-6 && (ellipse_center.y - circle_center.y).abs() < 1e-6, + "ellipse and circle centers should coincide after solving: ellipse={ellipse_center:?} circle={circle_center:?}" + ); +} + +#[test] +fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + None, + ); + + // Move the ellipse directly (bypassing the solver) before the first + // solve, same as the equivalent Circle test — the constraint is a + // symmetric point-equality, so what's checked is that both sides end + // up equal to *each other*, not at some hardcoded absolute position. + if let Some(EntityType::Ellipse(e)) = scene.document.get_entity_mut(ellipse) { + e.center = Vector3::new(-6.0, 9.0, 0.0); + } + scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); + + let (center, _, _) = ellipse_geom(&scene, ellipse); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (center.x - line_start.x).abs() < 1e-6 && (center.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the ellipse's center: center={center:?} start={line_start:?}" + ); +} + +#[test] +fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { + // The riskiest part of Ellipse support: converting acadrust's center + + // major-axis-*vector* + minor/major ratio into `ocs_gcs`'s center + + // focus + minor-radius, then back, for a major axis that ISN'T + // axis-aligned (an axis-aligned one wouldn't exercise the direction + // math at all). A solve with nothing actually pulling on it should + // reproduce the same ellipse, not drift. + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 2.0, -3.0, (4.0, 3.0), 0.6); // major radius 5, tilted + let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); + + let (center, major_axis, ratio) = ellipse_geom(&scene, ellipse); + assert!((center.x - 2.0).abs() < 1e-6 && (center.y - -3.0).abs() < 1e-6, "center should not have drifted: {center:?}"); + assert!((major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 3.0).abs() < 1e-6, "major axis should not have drifted: {major_axis:?}"); + assert!((ratio - 0.6).abs() < 1e-6, "minor/major ratio should not have drifted: {ratio}"); +} + +#[test] +fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); + let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); + set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], None); + + let before = ellipse_geom(&scene, ellipse); + + // Drag the line's start away — without Fixed this would pull the + // ellipse's center along with it; Fixed should hold the ellipse + // exactly in place and let the line's start follow back to it instead. + set_line_start(&mut scene, line, Vector3::new(20.0, -8.0, 0.0)); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let after = ellipse_geom(&scene, ellipse); + assert_eq!(before, after, "Fixed ellipse must not move at all (center, major axis, or ratio)"); + let (start, _) = line_endpoints(&scene, line); + assert!( + (start.x - before.0.x).abs() < 1e-6 && (start.y - before.0.y).abs() < 1e-6, + "line's start should have been pulled back to the (unmoved) ellipse center" + ); +} + +#[test] +fn distance_y_constraint_pins_only_the_y_component() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(-3.0)), + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!((end.y - start.y - -3.0).abs() < 1e-6, "y component should equal the (signed) target: dy={}", end.y - start.y); +} From 9f7972ee0d1206531214b05158ecfa1cf3eb8e8c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 11:24:30 +0200 Subject: [PATCH 10/99] Register all sketch-constraint commands in the autocomplete registry None of the 17 constraint commands (HCONSTRAINT, VCONSTRAINT, PCONSTRAINT, QCONSTRAINT, ECONSTRAINT, TCONSTRAINT, NCONSTRAINT, NRCONSTRAINT, LCONSTRAINT, FXCONSTRAINT, SYCONSTRAINT, CCONSTRAINT, EDCONSTRAINT, CPCONSTRAINT, MPCONSTRAINT, OCCONSTRAINT, DCONSTRAINT, ACONSTRAINT) carried an inventory::submit! registration, so they were invisible to command-line autocomplete, the MCP commands listing, and the ALIASEDIT shortcut editor (which rejects a custom shortcut target that isn't in this same registry). Each constraint module now registers itself, with a regression test pinning the full set. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 29 +++++++++++++- src/command.rs | 40 +++++++++++++++++++ src/modules/draw/constrain/coincident.rs | 3 ++ src/modules/draw/constrain/equal_distance.rs | 3 ++ src/modules/draw/constrain/point_on_entity.rs | 3 ++ src/modules/draw/constrain/tools.rs | 17 ++++++++ src/modules/draw/constrain/value.rs | 3 ++ 7 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d40cbc73d..06779d265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,34 @@ Notable changes on `feature/parametric-constraint-system` since each push to GitHub. Newest entries first. "Unreleased" covers local changes not yet pushed. -## Unreleased (since `eb96e8b6`) +## Unreleased (since `3d0a41b6`) + +- **Fix:** none of the 17 sketch-constraint commands (`HCONSTRAINT`, + `VCONSTRAINT`, `PCONSTRAINT`, `QCONSTRAINT`, `ECONSTRAINT`, `TCONSTRAINT`, + `NCONSTRAINT`, `NRCONSTRAINT`, `LCONSTRAINT`, `FXCONSTRAINT`, + `SYCONSTRAINT`, `CCONSTRAINT`, `EDCONSTRAINT`, `CPCONSTRAINT`, + `MPCONSTRAINT`, `OCCONSTRAINT`, `DCONSTRAINT`, `ACONSTRAINT`) carried an + `inventory::submit!` registration — a pre-existing gap the + `docs/command_reference.md` generator had already flagged, which meant + every constraint command (including this session's new `NRCONSTRAINT`) + was invisible to command-line autocomplete and the MCP server's + `commands` listing, despite working fine when typed exactly. Each + constraint module now registers itself, with a regression test pinning + it so the gap can't silently reappear. + - The same missing registration also broke the **keyboard-shortcut + editor** (`ALIASEDIT`): it validates a custom shortcut's target + against this same registry and silently rejected anything not in it, + so a user could not assign a custom shortcut to *any* constraint + command — not just the new ones, the original `HCONSTRAINT`/ + `CCONSTRAINT`/etc. family too. Fixed by the same change. + ([tools.rs](src/modules/draw/constrain/tools.rs), + [coincident.rs](src/modules/draw/constrain/coincident.rs), + [equal_distance.rs](src/modules/draw/constrain/equal_distance.rs), + [point_on_entity.rs](src/modules/draw/constrain/point_on_entity.rs), + [value.rs](src/modules/draw/constrain/value.rs), + [command.rs](src/command.rs)) + +## Pushed 2026-09-10 (`eb96e8b6` → `3d0a41b6`) - **Fix:** a dimensional constraint's value prompt (e.g. `DCONSTRAINT`'s "Specify distance:") rejected a named parameter typed in its own diff --git a/src/command.rs b/src/command.rs index c380356a2..8250dbfdb 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2445,3 +2445,43 @@ pub fn all_registered_command_names() -> Vec<&'static str> { .flat_map(|r| r.names.iter().copied()) .collect() } + +#[cfg(test)] +mod constraint_registry_tests { + use super::*; + + /// Regression guard: the whole `src/modules/draw/constrain/` family + /// (17 command ids, every one of the geometric/dimensional constraint + /// commands) predated `inventory::submit!` and was invisible to + /// command-line autocomplete and the MCP `commands` listing — + /// `docs/command_reference.md`'s own generator flagged this. Each + /// constraint module now registers itself; this pins that so the gap + /// can't silently reappear (e.g. a new constraint command added + /// without its own registration). + #[test] + fn every_constraint_command_is_in_the_autocomplete_registry() { + let names = all_registered_command_names(); + for id in [ + "CCONSTRAINT", + "EDCONSTRAINT", + "CPCONSTRAINT", + "MPCONSTRAINT", + "OCCONSTRAINT", + "HCONSTRAINT", + "VCONSTRAINT", + "PCONSTRAINT", + "QCONSTRAINT", + "ECONSTRAINT", + "TCONSTRAINT", + "NCONSTRAINT", + "NRCONSTRAINT", + "LCONSTRAINT", + "FXCONSTRAINT", + "SYCONSTRAINT", + "DCONSTRAINT", + "ACONSTRAINT", + ] { + assert!(names.contains(&id), "{id} is missing from the command registry"); + } + } +} diff --git a/src/modules/draw/constrain/coincident.rs b/src/modules/draw/constrain/coincident.rs index 6160a8789..b4da68fa3 100644 --- a/src/modules/draw/constrain/coincident.rs +++ b/src/modules/draw/constrain/coincident.rs @@ -71,3 +71,6 @@ impl CadCommand for CoincidentConstraintCommand { CmdResult::Cancel } } + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["CCONSTRAINT"] }); diff --git a/src/modules/draw/constrain/equal_distance.rs b/src/modules/draw/constrain/equal_distance.rs index ed648a458..f71725a3b 100644 --- a/src/modules/draw/constrain/equal_distance.rs +++ b/src/modules/draw/constrain/equal_distance.rs @@ -65,3 +65,6 @@ impl CadCommand for EqualDistanceConstraintCommand { CmdResult::Cancel } } + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["EDCONSTRAINT"] }); diff --git a/src/modules/draw/constrain/point_on_entity.rs b/src/modules/draw/constrain/point_on_entity.rs index 88b5e141a..f572a2439 100644 --- a/src/modules/draw/constrain/point_on_entity.rs +++ b/src/modules/draw/constrain/point_on_entity.rs @@ -89,3 +89,6 @@ impl CadCommand for PointOnEntityConstraintCommand { CmdResult::Cancel } } + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"] }); diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index 6ed14b260..aaf83458c 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -141,3 +141,20 @@ pub mod symmetric { } } } + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &[ + "HCONSTRAINT", + "VCONSTRAINT", + "PCONSTRAINT", + "QCONSTRAINT", + "ECONSTRAINT", + "TCONSTRAINT", + "NCONSTRAINT", + "NRCONSTRAINT", + "LCONSTRAINT", + "FXCONSTRAINT", + "SYCONSTRAINT", + ] +}); diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index 256253f23..5b7363f75 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -307,6 +307,9 @@ impl CadCommand for AngleConstraintCommand { } } +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["DCONSTRAINT", "ACONSTRAINT"] }); + #[cfg(test)] mod tests { use super::*; From d27eb625ca288c40259245183863debb93f174b4 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 19:25:24 +0200 Subject: [PATCH 11/99] fix(constraints): split large XRecord Chunk data across entries for DWG The DWG writer truncates an XRecord Chunk to u8::MAX bytes, so a sketch's constraint set or named-parameter table silently lost every entry on the next DWG open once serialization passed ~255 bytes (found live: a 23-constraint demo round-tripped fine through DXF but came back empty through DWG). Split the blob across multiple same-code 310 entries on save and reassemble them on load, entirely on the OCS side. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 17 +++++++++++ src/scene/named_parameters_persist.rs | 31 +++++++++++++------ src/scene/sketch_persist.rs | 43 ++++++++++++++++++++------- 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06779d265..78b2298fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ pushed. ## Unreleased (since `3d0a41b6`) +- **Fix:** a sketch's constraint set (or the named-parameter table) silently + lost *all* of its entries on the next DWG open once its serialized size + passed 255 bytes — roughly 5-6 constraints, easily reached by a real + sketch. Root cause: the vendored DWG writer packs an `XRecord` binary + `Chunk` entry's length as a single `u8` and truncates anything longer + (`cadcodec`'s `encode_xrecord_entries`), so the truncated bytes then + failed to `bincode::deserialize` on load and the loader silently dropped + the whole scope rather than erroring. DXF was unaffected (its writer has + no such cap), which is what made this findable — the exact same document + round-tripped constraint-for-constraint through DXF and came back empty + through DWG. Fixed entirely on the OCS side, no dependency patch needed: + the blob is now split across as many same-code `310` entries as it takes + (below 255 bytes each) on save, and every entry for that key is + concatenated back together on load, instead of just the first one. + ([sketch_persist.rs](src/scene/sketch_persist.rs), + [named_parameters_persist.rs](src/scene/named_parameters_persist.rs)) + - **Fix:** none of the 17 sketch-constraint commands (`HCONSTRAINT`, `VCONSTRAINT`, `PCONSTRAINT`, `QCONSTRAINT`, `ECONSTRAINT`, `TCONSTRAINT`, `NCONSTRAINT`, `NRCONSTRAINT`, `LCONSTRAINT`, `FXCONSTRAINT`, diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs index ff09b7085..d57aa1c01 100644 --- a/src/scene/named_parameters_persist.rs +++ b/src/scene/named_parameters_persist.rs @@ -48,6 +48,12 @@ use super::Scene; const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; +/// See `sketch_persist::MAX_CHUNK_BYTES`'s doc comment: the vendored DWG +/// writer truncates any single `XRecordValue::Chunk` entry to `u8::MAX` +/// bytes, so a table past a handful of parameters must be split across +/// multiple same-code `310` entries on write and concatenated back on read. +const MAX_CHUNK_BYTES: usize = u8::MAX as usize; + /// Prefixed onto the serialized blob so a future schema change can be /// detected and gracefully skipped rather than silently misreading bytes — /// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the @@ -91,11 +97,13 @@ impl Scene { self.document.ensure_xrecord(owner, XRECORD_KEY); if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { // Overwrite, not append: a resave must replace the prior blob, - // not accumulate one more Chunk entry every time. + // not accumulate more Chunk entries every time. record.entries.clear(); - record - .entries - .push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(bytes))); + for chunk in bytes.chunks(MAX_CHUNK_BYTES) { + record + .entries + .push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(chunk.to_vec()))); + } } } @@ -114,12 +122,17 @@ impl Scene { return; } let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return }; - let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value { - acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()), - _ => None, - }) else { + // Concatenate every Chunk entry in order, not just the first — see + // `MAX_CHUNK_BYTES`'s doc comment. + let mut bytes = Vec::new(); + for entry in &record.entries { + if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + bytes.extend_from_slice(chunk); + } + } + if bytes.is_empty() { return; - }; + } if let Some(table) = decode(&bytes) { self.named_parameters = table; } diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs index b0f224540..a51088cdf 100644 --- a/src/scene/sketch_persist.rs +++ b/src/scene/sketch_persist.rs @@ -21,6 +21,21 @@ use acadrust::{CadDocument, Handle}; const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS"; +/// The vendored DWG writer's `XRecordValue::Chunk` encoder packs a single +/// `u8` length prefix per entry and silently truncates anything longer +/// (`cadcodec`'s `io/dwg/dwg_stream_writers/object_writer/objects.rs`, +/// `encode_xrecord_entries`) — unlike the DXF writer, which has no such +/// limit. A constraint set past a handful of entries serializes past 255 +/// bytes easily, so a single `XRecordEntry` silently loses the tail and +/// `bincode::deserialize` then fails on the truncated bytes, dropping the +/// *entire* scope's constraints on the next DWG load — reproduced and +/// root-caused live (not from a change log): a 5-entry set round-tripped +/// clean, a 6-entry set of the same shapes lost everything. Splitting the +/// blob across as many same-code `310` entries as needed (below) works +/// around it without touching the vendored crate — `CadDocument::xrecord`'s +/// `entries: Vec` already supports repeating a code. +const MAX_CHUNK_BYTES: usize = u8::MAX as usize; + /// Removes a named XRecord from `owner`'s extension dictionary, if present. /// The dictionary itself is left in place even if now empty — other code may /// already reference it via `xdictionary_handle`, and an empty extension @@ -107,12 +122,14 @@ impl Scene { self.document.ensure_xrecord(owner, XRECORD_KEY); if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { // Overwrite, not append: a resave must replace the prior - // blob, not accumulate one more Chunk entry every time. + // blob, not accumulate more Chunk entries every time. record.entries.clear(); - record.entries.push(acadrust::objects::XRecordEntry::new( - 310, - acadrust::objects::XRecordValue::Chunk(bytes), - )); + for chunk in bytes.chunks(MAX_CHUNK_BYTES) { + record.entries.push(acadrust::objects::XRecordEntry::new( + 310, + acadrust::objects::XRecordValue::Chunk(chunk.to_vec()), + )); + } } } } @@ -132,12 +149,18 @@ impl Scene { self.document.block_records.iter().map(|record| record.handle).collect(); for owner in owners { let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { continue }; - let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value { - acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()), - _ => None, - }) else { + // Concatenate every Chunk entry in order, not just the first — + // a set materialized past `MAX_CHUNK_BYTES` spans more than one + // same-code `310` entry (see that constant's doc comment). + let mut bytes = Vec::new(); + for entry in &record.entries { + if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + bytes.extend_from_slice(chunk); + } + } + if bytes.is_empty() { continue; - }; + } if let Some(set) = decode(&bytes) { self.sketch_constraints.push(set); } From faf3473b2bb548106c9dc306d06dafcaf8006353 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 23:15:19 +0200 Subject: [PATCH 12/99] Fix ellipse solver drift: pin focus1 offset from center, not focus1 itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ocs_gcs Ellipse stores focus1 as an absolute point rather than an offset from center, so a constraint that pulls only on center (e.g. Concentric) left focus1's absolute coordinates untouched while center moved — changing the derived focus1-center vector that major_axis and minor_axis_ratio are computed from on write-back, so the ellipse visibly rotated and reshaped even though nothing asked it to. solve_scope now adds two Difference constraints per registered ellipse pinning focus1.x-center.x and focus1.y-center.y to their pre-solve values, so focus1 translates rigidly with center unless something actually constrains it. Fixed's Ellipse arm no longer re-pins focus1 directly since that's now redundant with these rules. Co-Authored-By: Claude Sonnet 5 --- src/scene/sketch_solve.rs | 75 +++++++++++++++++++++++++++---- tests/sketch_constraints_solve.rs | 42 +++++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index 3959c1db1..d2e36a030 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -50,6 +50,32 @@ //! `Normal`'s own inline matches (which don't go through `whole_circle`) //! have explicit `EntityGeom::Arc` arms alongside their `Circle` ones for //! the same reason. +//! +//! An `Ellipse` registers as `ocs_gcs::geo::Ellipse { center, focus1, +//! radmin }` (`register_entity`'s `Ellipse` arm converts acadrust's +//! center/major-axis-vector/ratio parametrization to this one). Unlike +//! Arc's `start`/`end`, `focus1` isn't an extra derived point on top of the +//! "real" shape params — it *is* one, jointly with `radmin`, encoding +//! orientation and eccentricity. But it's stored as an absolute point, not +//! an offset from `center`, so a constraint that pulls only on `center` +//! (Concentric/CenterPoint being the natural case) leaves `focus1` +//! genuinely untouched — correct for the solver, wrong for the geometry: +//! `center` moved, `focus1` didn't, so the vector `focus1 - center` (what +//! write-back actually derives `major_axis`/`minor_axis_ratio` from) has +//! changed, and the ellipse appears to rotate and reshape even though +//! nothing asked it to. `solve_scope` adds two ellipse-rules +//! `ocs_gcs::constraints::point_line::Difference` constraints per +//! registered ellipse — `focus1.x - center.x` and `focus1.y - center.y` +//! pinned to their pre-solve values — so `focus1` translates rigidly with +//! `center` whenever nothing else constrains it, the same "stays put +//! unless something actually pulls on it" behavior Arc's own spare DOF +//! already has. `radmin` needs no equivalent: as a lone scalar it isn't +//! coupled to `center`'s movement, so it already stays at its seed value +//! once unreferenced by anything. Because these rules already pin `focus1` +//! once `center` is pinned, `Fixed`'s `Ellipse` arm only pins `center`/ +//! `radmin` directly, not `focus1` — pinning it too would be redundant +//! with the rules, same as `Fixed`'s `Arc` arm not re-pinning `start`/ +//! `end`. use std::collections::HashMap; use std::rc::Rc; @@ -85,10 +111,12 @@ enum EntityGeom { /// for every registered arc. See this module's doc comment. Arc(GArc), /// Center-and-shape support only — see `register_entity`'s `Ellipse` - /// arm for the acadrust-to-`ocs_gcs` parametrization conversion, and - /// this module's doc comment for what isn't wired up yet (major/minor - /// axis dimensional constraints, `PointOnEllipse`-based Coincident/ - /// Tangent). + /// arm for the acadrust-to-`ocs_gcs` parametrization conversion, the + /// ellipse-rules `Difference` constraints `solve_scope` adds for every + /// registered ellipse to keep `focus1` translating rigidly with + /// `center` (this module's doc comment), and that same doc comment for + /// what isn't wired up yet (major/minor axis dimensional constraints, + /// `PointOnEllipse`-based Coincident/Tangent). Ellipse(GEllipse), } @@ -412,16 +440,20 @@ fn build_constraint( Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), ] } + // Pinning center + radmin is enough — `focus1` is already + // tied to `center` by the ellipse-rules `Difference` + // constraints `solve_scope` adds for every registered + // ellipse (see this module's doc comment), so with `center` + // pinned here too, pinning `focus1` again would just be + // redundant (same reasoning as Arc's `Fixed` arm above). EntityGeom::Ellipse(el) => { - let (cx, cy, fx, fy, b) = { + let (cx, cy, b) = { let store = sys.store(); - (store.get(el.center.x), store.get(el.center.y), store.get(el.focus1.x), store.get(el.focus1.y), store.get(el.radmin)) + (store.get(el.center.x), store.get(el.center.y), store.get(el.radmin)) }; vec![ Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(el.focus1.x, sys.add_param(fx, true), 1.0)), - Rc::new(Equal::new(el.focus1.y, sys.add_param(fy, true), 1.0)), Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), ] } @@ -615,6 +647,33 @@ fn solve_scope( sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.y, curve, arc.end_angle))); } + // Ellipse rules (same motivation as arc rules, different mechanism): + // `ocs_gcs::geo::Ellipse` stores `focus1` as an absolute point, not an + // offset from `center`. If some constraint (e.g. Concentric) pulls only + // on `center` and nothing references `focus1`/`radmin`, the solver + // correctly leaves `focus1`'s *absolute* coordinates untouched — but + // `center` moved and `focus1` didn't, so the derived vector + // `focus1 - center` (which is what `major_axis` direction/length and + // `minor_axis_ratio` are actually computed from on write-back) changes + // anyway, making the ellipse appear to rotate and reshape even though + // no shape parameter was itself pulled on. Pinning `focus1 - center` to + // its pre-solve (seed) value forces `focus1` to translate rigidly with + // `center`, keeping orientation/eccentricity invariant unless something + // actually constrains `focus1`/`radmin` directly (only `Fixed` does + // today). `radmin` needs no equivalent rule — as a lone scalar it isn't + // coupled to `center`'s movement, so it already stays put on its own. + for geom in cache.values() { + let EntityGeom::Ellipse(el) = geom else { continue }; + let (cx, cy, fx, fy) = { + let store = sys.store(); + (store.get(el.center.x), store.get(el.center.y), store.get(el.focus1.x), store.get(el.focus1.y)) + }; + let dx = sys.add_param(fx - cx, true); + let dy = sys.add_param(fy - cy, true); + sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); + sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + } + let partitions = sys.partition(); for sub in &partitions { solve_dl(sub, sys.store_mut()); diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index 194bcd597..0c326db38 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -947,6 +947,48 @@ fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { assert!((ratio - 0.6).abs() < 1e-6, "minor/major ratio should not have drifted: {ratio}"); } +#[test] +fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_moves() { + // Regression test for a real solver bug: `a_tilted_ellipse_round_trips_ + // through_the_solver_without_drifting` (above) uses a circle that's + // ALREADY concentric with the ellipse, so Concentric's equations start + // out satisfied and the ellipse's center never actually has to move — + // it doesn't exercise the bug at all. Here the circle starts elsewhere, + // forcing a real center move, which is exactly when `ocs_gcs::geo:: + // Ellipse`'s absolute `focus1` point used to get left behind: `center` + // moved to meet the circle, `focus1` didn't (nothing else referenced + // it), so the derived major-axis direction/length and minor/major + // ratio changed even though only a center-to-center constraint was + // ever applied. The fix ties `focus1`'s offset from `center` to its + // seeded value, so it translates along with `center` instead. + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (4.0, 0.0), 0.5); + let circle = add_circle(&mut scene, 10.0, 7.0, 1.5); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(ellipse, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + + let (ellipse_center, major_axis, ratio) = ellipse_geom(&scene, ellipse); + let (circle_center, _) = circle_geom(&scene, circle); + + assert!( + (ellipse_center.x - circle_center.x).abs() < 1e-6 && (ellipse_center.y - circle_center.y).abs() < 1e-6, + "concentric constraint should still hold: ellipse={ellipse_center:?} circle={circle_center:?}" + ); + // The whole point: the center was free to move (nothing else pinned + // either entity), so it's not expected to still sit at (0,0) — but the + // ellipse's *shape* must be exactly what it was before solving. + assert!( + (major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 0.0).abs() < 1e-6, + "major axis must not rotate or resize just because the center moved: {major_axis:?}" + ); + assert!((ratio - 0.5).abs() < 1e-6, "minor/major ratio must not change just because the center moved: {ratio}"); +} + #[test] fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { let mut scene = Scene::new(); From bd9a2d08f77b17d22b54263373c810c919cfc4d0 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 23:16:11 +0200 Subject: [PATCH 13/99] Add Properties-panel Constraints/Parameters sections, a constraint-pill value toggle, and redesigned constraint-ribbon icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties panel: - New "Parameters" section (shown with no selection) listing every named parameter with inline name/formula editing, add, and delete, backed by new PropValue::ParamRow/ParamAddRow and command_driver handlers (resolve_named_parameter_edit, on_prop_param_commit/_delete/_add_new). A fresh parameter's formula now defaults to "1", not "0" — a 0-length seed value is a genuine solver singularity for a Distance constraint driven by that parameter, with no defined direction to grow back out of. - New "Constraints" section (shown for a single selected entity) listing every constraint touching it as a clickable PropValue::EntityLink that selects the linked entities in the viewport, danger-tinted when the constraint conflicts. Constraint pills: - New Options > General toggle, "Show values and parameter names on constraint markers" (on by default, persisted). Off, every pill shows just its bare glyph instead of glyph+driven-value/parameter-name — the glyph alone is enough to see a constraint is present, without the value text covering nearby geometry on a dense sketch. Constraint-ribbon icons: - Replaced all 18 unicode/letter glyph icons (including the plain "F", "L", "M", "S" letters) with proper SVGs under assets/icons/constrain/, matching the Modify section's two-color style (#B4B6B9 white / #6DB7ED blue, consistent stroke width). Tangent reuses the Draw section's white-arc-plus-blue-dots motif. Co-Authored-By: Claude Sonnet 5 --- assets/icons/constrain/angle.svg | 5 + assets/icons/constrain/center_point.svg | 4 + assets/icons/constrain/coincident.svg | 4 + assets/icons/constrain/colinear.svg | 4 + assets/icons/constrain/concentric.svg | 4 + assets/icons/constrain/distance.svg | 7 + assets/icons/constrain/equal.svg | 4 + assets/icons/constrain/equal_distance.svg | 8 + assets/icons/constrain/fixed.svg | 7 + assets/icons/constrain/horizontal.svg | 3 + assets/icons/constrain/midpoint.svg | 4 + assets/icons/constrain/normal.svg | 5 + assets/icons/constrain/parallel.svg | 4 + assets/icons/constrain/perpendicular.svg | 4 + assets/icons/constrain/point_on_curve.svg | 4 + assets/icons/constrain/symmetric.svg | 6 + assets/icons/constrain/tangent.svg | 5 + assets/icons/constrain/vertical.svg | 3 + src/app/command_driver.rs | 215 ++++++++++++++++++ src/app/mod.rs | 36 +++ src/app/properties.rs | 88 +++++++ src/app/settings.rs | 11 + src/app/update/file.rs | 2 + src/app/update/mod.rs | 27 +++ src/app/view/mod.rs | 10 +- src/app/view/modal.rs | 1 + src/modules/draw/constrain/coincident.rs | 2 +- src/modules/draw/constrain/equal_distance.rs | 2 +- src/modules/draw/constrain/point_on_entity.rs | 6 +- src/modules/draw/constrain/tools.rs | 22 +- src/modules/draw/constrain/value.rs | 4 +- src/scene/model/object.rs | 24 +- src/scene/selection.rs | 10 +- src/ui/properties.rs | 168 ++++++++++++++ src/ui/window/named_parameters.rs | 2 +- src/ui/window/options.rs | 18 ++ 36 files changed, 707 insertions(+), 26 deletions(-) create mode 100644 assets/icons/constrain/angle.svg create mode 100644 assets/icons/constrain/center_point.svg create mode 100644 assets/icons/constrain/coincident.svg create mode 100644 assets/icons/constrain/colinear.svg create mode 100644 assets/icons/constrain/concentric.svg create mode 100644 assets/icons/constrain/distance.svg create mode 100644 assets/icons/constrain/equal.svg create mode 100644 assets/icons/constrain/equal_distance.svg create mode 100644 assets/icons/constrain/fixed.svg create mode 100644 assets/icons/constrain/horizontal.svg create mode 100644 assets/icons/constrain/midpoint.svg create mode 100644 assets/icons/constrain/normal.svg create mode 100644 assets/icons/constrain/parallel.svg create mode 100644 assets/icons/constrain/perpendicular.svg create mode 100644 assets/icons/constrain/point_on_curve.svg create mode 100644 assets/icons/constrain/symmetric.svg create mode 100644 assets/icons/constrain/tangent.svg create mode 100644 assets/icons/constrain/vertical.svg diff --git a/assets/icons/constrain/angle.svg b/assets/icons/constrain/angle.svg new file mode 100644 index 000000000..e632dd2c8 --- /dev/null +++ b/assets/icons/constrain/angle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/constrain/center_point.svg b/assets/icons/constrain/center_point.svg new file mode 100644 index 000000000..cb4cb7333 --- /dev/null +++ b/assets/icons/constrain/center_point.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/coincident.svg b/assets/icons/constrain/coincident.svg new file mode 100644 index 000000000..263234192 --- /dev/null +++ b/assets/icons/constrain/coincident.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/colinear.svg b/assets/icons/constrain/colinear.svg new file mode 100644 index 000000000..be8d671c9 --- /dev/null +++ b/assets/icons/constrain/colinear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/concentric.svg b/assets/icons/constrain/concentric.svg new file mode 100644 index 000000000..d68acc43b --- /dev/null +++ b/assets/icons/constrain/concentric.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/distance.svg b/assets/icons/constrain/distance.svg new file mode 100644 index 000000000..278ee92ee --- /dev/null +++ b/assets/icons/constrain/distance.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/constrain/equal.svg b/assets/icons/constrain/equal.svg new file mode 100644 index 000000000..bee8e3fec --- /dev/null +++ b/assets/icons/constrain/equal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/equal_distance.svg b/assets/icons/constrain/equal_distance.svg new file mode 100644 index 000000000..9b027b7fd --- /dev/null +++ b/assets/icons/constrain/equal_distance.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/constrain/fixed.svg b/assets/icons/constrain/fixed.svg new file mode 100644 index 000000000..b0e75becb --- /dev/null +++ b/assets/icons/constrain/fixed.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/constrain/horizontal.svg b/assets/icons/constrain/horizontal.svg new file mode 100644 index 000000000..2259c70bf --- /dev/null +++ b/assets/icons/constrain/horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/constrain/midpoint.svg b/assets/icons/constrain/midpoint.svg new file mode 100644 index 000000000..23ac2c0f1 --- /dev/null +++ b/assets/icons/constrain/midpoint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/normal.svg b/assets/icons/constrain/normal.svg new file mode 100644 index 000000000..f4ddef0cd --- /dev/null +++ b/assets/icons/constrain/normal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/constrain/parallel.svg b/assets/icons/constrain/parallel.svg new file mode 100644 index 000000000..891fd41f1 --- /dev/null +++ b/assets/icons/constrain/parallel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/perpendicular.svg b/assets/icons/constrain/perpendicular.svg new file mode 100644 index 000000000..520becabb --- /dev/null +++ b/assets/icons/constrain/perpendicular.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/point_on_curve.svg b/assets/icons/constrain/point_on_curve.svg new file mode 100644 index 000000000..745845a1e --- /dev/null +++ b/assets/icons/constrain/point_on_curve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/symmetric.svg b/assets/icons/constrain/symmetric.svg new file mode 100644 index 000000000..6a73ba883 --- /dev/null +++ b/assets/icons/constrain/symmetric.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/constrain/tangent.svg b/assets/icons/constrain/tangent.svg new file mode 100644 index 000000000..7d0422129 --- /dev/null +++ b/assets/icons/constrain/tangent.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/constrain/vertical.svg b/assets/icons/constrain/vertical.svg new file mode 100644 index 000000000..81c68fe6e --- /dev/null +++ b/assets/icons/constrain/vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 68c44ca5c..218067ef5 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1051,6 +1051,147 @@ impl OpenCADStudio { } } + /// Re-solves every entity touched by an enabled constraint driven by + /// `name` — the targeted, single-row counterpart to + /// `apply_named_parameter_editor_rows`'s "re-solve what it affects": an + /// inline Properties-panel edit changes one parameter, not the whole + /// table, so unlike that whole-table Apply this can cheaply scope the + /// re-solve to just the entities that actually reference it. + fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { + let touched: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) + }) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + self.tabs[i].dirty = true; + if touched.is_empty() { + return; + } + let pending = self.begin_undo(i, "Edit named parameter", touched.len(), true); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = + touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// Commits one field of one Parameters-section row (Properties panel) — + /// the per-row, commit-on-submit counterpart to + /// `apply_named_parameter_editor_rows`'s whole-table Apply. A Formula + /// edit is a plain redefine (`ParameterTable::set` on the existing + /// name); a Name edit is a remove-then-set (there's no rename primitive + /// — `ParameterTable` upserts by name), restoring the old name if the + /// new one fails to validate so nothing is silently lost. Either way + /// this only re-solves what actually depends on this one parameter — + /// no "duplicate name" scan across the whole table is needed the way + /// the buffered modal Apply needs one, since every row here is always + /// already a real, distinct table entry. + pub(super) fn on_prop_param_commit( + &mut self, + index: usize, + field: crate::ui::window::named_parameters::ParamField, + ) -> Task { + use crate::ui::properties::FieldKey; + use crate::ui::window::named_parameters::ParamField; + let i = self.active_tab; + let key = FieldKey::Param(index, field); + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i].properties.edit_buf.remove(&key) else { + return Task::none(); + }; + let typed = typed.trim().to_string(); + let Some(current) = self.tabs[i].scene.named_parameters().iter().nth(index).cloned() else { + self.refresh_properties(); + return Task::none(); + }; + + let (result, resolve_name) = match field { + ParamField::Formula => { + if typed.is_empty() || typed == current.source { + self.refresh_properties(); + return Task::none(); + } + (self.tabs[i].scene.named_parameters_mut().set(¤t.name, &typed), current.name.clone()) + } + ParamField::Name => { + if typed.is_empty() || typed == current.name { + self.refresh_properties(); + return Task::none(); + } + if self.tabs[i].scene.named_parameters().contains(&typed) { + self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); + self.refresh_properties(); + return Task::none(); + } + self.tabs[i].scene.named_parameters_mut().remove(¤t.name); + let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.source); + if outcome.is_err() { + let _ = self.tabs[i].scene.named_parameters_mut().set(¤t.name, ¤t.source); + } + (outcome, typed.clone()) + } + }; + + if let Err(e) = result { + self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); + self.refresh_properties(); + return Task::none(); + } + self.resolve_named_parameter_edit(i, &resolve_name); + self.refresh_properties(); + Task::none() + } + + /// Removes Parameters-section row `index` immediately — no confirmation, + /// matching AutoCAD's own Parameters Manager delete button. Any + /// constraint that referenced it gets the same "undefined reference" + /// resolve-failure treatment a formula's own bad reference already gets + /// (`build_constraint`) — re-solving surfaces that rather than needing + /// special-case handling here. + pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task { + let i = self.active_tab; + let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else { + return Task::none(); + }; + self.tabs[i].scene.named_parameters_mut().remove(&name); + self.resolve_named_parameter_edit(i, &name); + self.refresh_properties(); + Task::none() + } + + /// The Parameters section's "+ Add parameter" row: appends a fresh, + /// uniquely-named parameter (`param1`, `param2`, …) the user then + /// renames/redefines inline via `on_prop_param_commit`. Defaults to `1`, + /// not `0`: renaming this onto a name an existing Distance/Radius + /// constraint already references (unusual, but reachable — define + /// first, wire up the constraint's reference later) would otherwise + /// briefly collapse that geometry to a zero-length/zero-radius + /// degenerate state, which is a genuine numerical singularity for a + /// point-to-point distance constraint to grow back out of (no defined + /// direction to separate two exactly-coincident points from). `1` never + /// hits that. + pub(super) fn on_prop_param_add_new(&mut self) -> Task { + let i = self.active_tab; + let mut n = 1usize; + let name = loop { + let candidate = format!("param{n}"); + if !self.tabs[i].scene.named_parameters().contains(&candidate) { + break candidate; + } + n += 1; + }; + let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); + self.refresh_properties(); + Task::none() + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -6370,6 +6511,80 @@ mod sketch_constraint_undo_tests { assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); } + /// End-to-end through the actual `Message` handlers a Properties-panel + /// click/keystroke would fire — not `apply_named_parameter_editor_rows` + /// directly — covering the whole Parameters-section lifecycle: add, + /// rename, redefine, and confirm a referencing constraint re-solves + /// after each commit (not just after a whole-table Apply, since this + /// path commits per field). + #[test] + fn properties_panel_parameter_row_add_rename_redefine_and_delete() { + use crate::ui::window::named_parameters::ParamField; + + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Distance, + refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("len".to_string())), + label: "Distance constraint", + }); + + // Add: the first row is auto-named "param1". + let _ = app.update(Message::PropParamAddNew); + assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1")); + let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap(); + + // Rename param1 -> len (commit-on-submit, not per keystroke: input + // alone must not touch the table yet). + let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string() }); + assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"), "typing alone must not commit"); + let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name }); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("param1")); + assert!(app.tabs[app.active_tab].scene.named_parameters().contains("len")); + + // Redefine its formula to 8 and confirm the Distance constraint + // (already referencing "len" by name, defined before this) re-solves. + let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() }); + let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula }); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("len"), Ok(8.0)); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + + // Delete: the row is gone from the table. + let _ = app.update(Message::PropParamDelete(index)); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("len")); + } + + /// A Constraints-section row click selects every entity the constraint + /// references, replacing whatever was selected before. + #[test] + fn properties_panel_constraint_link_click_selects_its_entities() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); + let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Parallel, + refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + driving_param: None, + label: "Parallel constraint", + }); + app.tabs[app.active_tab].scene.select_entity(unrelated, true); + + let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); + + let selected: std::collections::HashSet = + app.tabs[app.active_tab].scene.selected_entities().into_iter().map(|(h, _)| h).collect(); + assert_eq!(selected, std::collections::HashSet::from([a, b])); + } + /// A row that fails to validate (here, a formula that doesn't parse) /// must not silently vanish or corrupt the table: it's reported as an /// error and simply isn't added, while a valid row alongside it still diff --git a/src/app/mod.rs b/src/app/mod.rs index 73a73d6fd..f19b01c19 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -557,6 +557,13 @@ pub(super) struct OpenCADStudio { /// this only governs whether the graph is *created* in a scope that /// doesn't have one yet. pub write_dwg_native_constraints: bool, + /// When true (default), a sketch constraint's viewport pill shows its + /// glyph plus a driven value or named-parameter name (e.g. "⌀ 5.000" or + /// "⌀ hole_dia"). When false, every pill shows just the bare glyph — the + /// value/name text can cover important canvas detail on a dense sketch, + /// and the glyph alone is already enough to see *that* a constraint is + /// present (`docs/parametric_system_design.md` §6.3/§7). + pub show_constraint_values: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -2046,6 +2053,9 @@ pub enum Message { /// Toggle writing AutoCAD-native constraint objects on save, from /// Options. See `write_dwg_native_constraints`'s doc comment. WriteDwgNativeConstraintsChanged(bool), + /// Toggle showing driven values/named-parameter names on constraint + /// pills, from Options. See `show_constraint_values`'s doc comment. + ShowConstraintValuesChanged(bool), /// Switch the interface language and redraw localized views. LanguageChanged(crate::i18n::Language), /// Drop every entity from the active drawing. @@ -2790,6 +2800,31 @@ pub enum Message { /// and re-solve every constraint that reads a named parameter; stays /// open. NamedParametersApply, + // ── Parameters / Constraints sections embedded in the Properties panel + // (AutoCAD-style: a live section instead of the modal above, per-row + // commit-on-submit instead of a whole-table Apply) ───────────────────── + /// Live text of one column of parameter row `index`, keyed by its + /// `ParameterTable::iter()` position — see `PropValue::ParamRow`. + PropParamInput { + index: usize, + field: crate::ui::window::named_parameters::ParamField, + value: String, + }, + /// Commit row `index`'s buffered edit for `field` to `Scene:: + /// named_parameters` (Enter / losing focus) and re-solve whatever it + /// drives. + PropParamCommit { + index: usize, + field: crate::ui::window::named_parameters::ParamField, + }, + /// Remove parameter row `index` immediately (no confirmation, matching + /// AutoCAD's own Parameters Manager delete button). + PropParamDelete(usize), + /// Append a fresh, uniquely-named parameter to `Scene::named_parameters`. + PropParamAddNew, + /// A Constraints-section row was clicked: select every entity in the + /// list (replacing the current selection) in the viewport. + PropConstraintLinkClick(Vec), // ── About window ──────────────────────────────────────────────────── AboutOpen, /// Close whatever in-canvas modal dialog is open (Plan B). @@ -3479,6 +3514,7 @@ impl OpenCADStudio { backup_on_save: true, file_assoc_enabled: true, write_dwg_native_constraints: false, + show_constraint_values: true, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 8fd1faec1..47501b87a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -406,6 +406,38 @@ impl OpenCADStudio { read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()), ], }, + // Document-wide named-parameter table (AutoCAD + // Parameters Manager equivalent), embedded here + // instead of a separate modal — see `PropValue:: + // ParamRow`'s doc comment. Belongs on the no- + // selection (drawing-level) page, not a per-entity + // one: a parameter isn't owned by any one entity. + PropSection { + title: t!("Parameters").into_owned(), + props: { + let mut props: Vec = scene + .named_parameters() + .iter() + .enumerate() + .map(|(index, p)| Property { + label: String::new(), + field: "named_parameter", + value: PropValue::ParamRow { + index, + name: p.name.clone(), + formula: p.source.clone(), + resolved: scene.named_parameters().resolve(&p.name).map_err(|e| e.to_string()), + }, + }) + .collect(); + props.push(Property { + label: String::new(), + field: "named_parameter_add", + value: PropValue::ParamAddRow, + }); + props + }, + }, ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), @@ -2126,6 +2158,53 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } + // Persistent sketch-constraint list for this entity — + // the Properties-panel analogue of the viewport's + // constraint glyph pills: one row per constraint + // touching `handle`, clickable to select every entity + // it relates (PropConstraintLinkClick). Omitted + // entirely when the entity has none — an unconstrained + // entity (the common case) shouldn't gain empty + // bookkeeping. + { + let scope = self.tabs[i].current_sketch_scope(); + let scene = &self.tabs[i].scene; + if let Some(set) = scene.sketch_constraint_set(scope) { + let props: Vec = set + .constraints_touching(handle) + .map(|c| { + let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); + let value_suffix = match &c.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => { + format!(" = {v:.2}") + } + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => { + match scene.named_parameters().resolve(name) { + Ok(v) => format!(" = {name} ({v:.2})"), + Err(_) => format!(" = {name} (?)"), + } + } + None => String::new(), + }; + let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix); + let mut handles: Vec = c.refs.iter().map(|r| r.entity).collect(); + handles.sort_by_key(|h| h.value()); + handles.dedup(); + crate::scene::model::object::Property { + label, + field: "sketch_constraint", + value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting }, + } + }) + .collect(); + if !props.is_empty() { + sections.push(crate::scene::model::object::PropSection { + title: t!("Constraints").into_owned(), + props, + }); + } + } + } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] @@ -3102,6 +3181,15 @@ fn make_sections_read_only( } PropValue::Stepper { display, .. } => display.clone(), PropValue::AttrText { value, .. } => value.clone(), + // The Constraints/Parameters sections aren't per-entity editable + // fields to begin with (a constraint link is a navigation + // action, not a value; a parameter belongs to the whole + // document, not the locked entity) — locking the entity doesn't + // change either, so just carry through readable text instead of + // collapsing them to a value that was never editable anyway. + PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()), + PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"), + PropValue::ParamAddRow => String::new(), }; property.field = "locked_read_only"; property.value = PropValue::ReadOnly(text); diff --git a/src/app/settings.rs b/src/app/settings.rs index 7e244126d..1f8fa5d5e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -244,6 +244,12 @@ pub struct UserSettings { /// graph keeps it synced regardless of this setting). #[serde(default)] pub write_dwg_native_constraints: bool, + /// When true (default), a sketch constraint's viewport pill shows its + /// glyph plus a driven value or named-parameter name. When false, every + /// pill shows just the bare glyph, so the value/name text doesn't cover + /// canvas detail on a dense sketch. + #[serde(default = "default_show_constraint_values")] + pub show_constraint_values: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -312,6 +318,10 @@ fn default_dimension_continue_mode() -> i16 { 1 } +fn default_show_constraint_values() -> bool { + true +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -361,6 +371,7 @@ impl Default for UserSettings { backup_on_save: true, file_assoc_enabled: true, write_dwg_native_constraints: false, + show_constraint_values: true, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 9c5aaab3b..b3465f2e3 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -397,6 +397,7 @@ impl OpenCADStudio { backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, write_dwg_native_constraints: self.write_dwg_native_constraints, + show_constraint_values: self.show_constraint_values, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -467,6 +468,7 @@ impl OpenCADStudio { self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; self.write_dwg_native_constraints = s.write_dwg_native_constraints; + self.show_constraint_values = s.show_constraint_values; self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 40b167542..0436a531f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -5949,6 +5949,28 @@ impl OpenCADStudio { Task::none() } + // ── Parameters / Constraints sections embedded in the + // Properties panel ────────────────────────────────────────────── + Message::PropParamInput { index, field, value } => { + self.tabs[self.active_tab] + .properties + .edit_buf + .insert(crate::ui::properties::FieldKey::Param(index, field), value); + Task::none() + } + Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field), + Message::PropParamDelete(index) => self.on_prop_param_delete(index), + Message::PropParamAddNew => self.on_prop_param_add_new(), + Message::PropConstraintLinkClick(handles) => { + let i = self.active_tab; + self.tabs[i].scene.deselect_all(); + for h in handles { + self.tabs[i].scene.select_entity(h, false); + } + self.refresh_properties(); + Task::none() + } + // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { self.active_modal = Some(super::ModalKind::Options); @@ -6312,6 +6334,11 @@ impl OpenCADStudio { self.persist_settings_if_changed(); Task::none() } + Message::ShowConstraintValuesChanged(enabled) => { + self.show_constraint_values = enabled; + self.persist_settings_if_changed(); + Task::none() + } Message::LanguageChanged(language) => { if self.language == language { return Task::none(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 05d1abbde..c935be08a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -839,10 +839,12 @@ bg={bg_ms:.1}ms n={view_count}" )?; let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); - point - .x - .is_finite() - .then(|| (point, crate::scene::sketch_constraints::glyph_label(c), is_conflicting)) + let label = if self.show_constraint_values { + crate::scene::sketch_constraints::glyph_label(c) + } else { + c.kind.glyph_symbol().to_string() + }; + point.x.is_finite().then(|| (point, label, is_conflicting)) }) .collect() } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 60cd94d7f..2e749248a 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -198,6 +198,7 @@ impl OpenCADStudio { &self.default_save_format, self.file_assoc_enabled, self.write_dwg_native_constraints, + self.show_constraint_values, &self.ui_theme, &self.theme_color_inputs, self.language, diff --git a/src/modules/draw/constrain/coincident.rs b/src/modules/draw/constrain/coincident.rs index b4da68fa3..53ad9ba0b 100644 --- a/src/modules/draw/constrain/coincident.rs +++ b/src/modules/draw/constrain/coincident.rs @@ -23,7 +23,7 @@ pub mod coincident_tool { ToolDef { id: "CCONSTRAINT", label: "Coincident", - icon: IconKind::Glyph("≡"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/coincident.svg")), event: ModuleEvent::Command("CCONSTRAINT".to_string()), } } diff --git a/src/modules/draw/constrain/equal_distance.rs b/src/modules/draw/constrain/equal_distance.rs index f71725a3b..745b21f4f 100644 --- a/src/modules/draw/constrain/equal_distance.rs +++ b/src/modules/draw/constrain/equal_distance.rs @@ -17,7 +17,7 @@ pub mod equal_distance_tool { ToolDef { id: "EDCONSTRAINT", label: "Equal Distance", - icon: IconKind::Glyph("≐"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/equal_distance.svg")), event: ModuleEvent::Command("EDCONSTRAINT".to_string()), } } diff --git a/src/modules/draw/constrain/point_on_entity.rs b/src/modules/draw/constrain/point_on_entity.rs index f572a2439..f4cc523b5 100644 --- a/src/modules/draw/constrain/point_on_entity.rs +++ b/src/modules/draw/constrain/point_on_entity.rs @@ -24,7 +24,7 @@ pub mod center_point_tool { ToolDef { id: "CPCONSTRAINT", label: "Center Point", - icon: IconKind::Glyph("⊕"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/center_point.svg")), event: ModuleEvent::Command("CPCONSTRAINT".to_string()), } } @@ -36,7 +36,7 @@ pub mod midpoint_tool { ToolDef { id: "MPCONSTRAINT", label: "Midpoint", - icon: IconKind::Glyph("M"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/midpoint.svg")), event: ModuleEvent::Command("MPCONSTRAINT".to_string()), } } @@ -48,7 +48,7 @@ pub mod point_on_curve_tool { ToolDef { id: "OCCONSTRAINT", label: "Point on Curve", - icon: IconKind::Glyph("∈"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/point_on_curve.svg")), event: ModuleEvent::Command("OCCONSTRAINT".to_string()), } } diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index aaf83458c..79d2777e0 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -8,7 +8,7 @@ pub mod horizontal { ToolDef { id: "HCONSTRAINT", label: "Horizontal", - icon: IconKind::Glyph("—"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/horizontal.svg")), event: ModuleEvent::Command("HCONSTRAINT".to_string()), } } @@ -20,7 +20,7 @@ pub mod vertical { ToolDef { id: "VCONSTRAINT", label: "Vertical", - icon: IconKind::Glyph("│"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/vertical.svg")), event: ModuleEvent::Command("VCONSTRAINT".to_string()), } } @@ -32,7 +32,7 @@ pub mod parallel { ToolDef { id: "PCONSTRAINT", label: "Parallel", - icon: IconKind::Glyph("∥"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/parallel.svg")), event: ModuleEvent::Command("PCONSTRAINT".to_string()), } } @@ -44,7 +44,7 @@ pub mod perpendicular { ToolDef { id: "QCONSTRAINT", label: "Perpendicular", - icon: IconKind::Glyph("⊥"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/perpendicular.svg")), event: ModuleEvent::Command("QCONSTRAINT".to_string()), } } @@ -56,7 +56,7 @@ pub mod equal { ToolDef { id: "ECONSTRAINT", label: "Equal", - icon: IconKind::Glyph("="), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/equal.svg")), event: ModuleEvent::Command("ECONSTRAINT".to_string()), } } @@ -68,7 +68,7 @@ pub mod tangent { ToolDef { id: "TCONSTRAINT", label: "Tangent", - icon: IconKind::Glyph("⌒"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/tangent.svg")), event: ModuleEvent::Command("TCONSTRAINT".to_string()), } } @@ -80,7 +80,7 @@ pub mod concentric { ToolDef { id: "NCONSTRAINT", label: "Concentric", - icon: IconKind::Glyph("◎"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/concentric.svg")), event: ModuleEvent::Command("NCONSTRAINT".to_string()), } } @@ -100,7 +100,7 @@ pub mod normal { ToolDef { id: "NRCONSTRAINT", label: "Normal", - icon: IconKind::Glyph("⊾"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/normal.svg")), event: ModuleEvent::Command("NRCONSTRAINT".to_string()), } } @@ -112,7 +112,7 @@ pub mod colinear { ToolDef { id: "LCONSTRAINT", label: "Colinear", - icon: IconKind::Glyph("L"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/colinear.svg")), event: ModuleEvent::Command("LCONSTRAINT".to_string()), } } @@ -124,7 +124,7 @@ pub mod fixed { ToolDef { id: "FXCONSTRAINT", label: "Fixed", - icon: IconKind::Glyph("F"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/fixed.svg")), event: ModuleEvent::Command("FXCONSTRAINT".to_string()), } } @@ -136,7 +136,7 @@ pub mod symmetric { ToolDef { id: "SYCONSTRAINT", label: "Symmetric", - icon: IconKind::Glyph("S"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/symmetric.svg")), event: ModuleEvent::Command("SYCONSTRAINT".to_string()), } } diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index 5b7363f75..8809d284f 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -57,7 +57,7 @@ pub mod distance_tool { ToolDef { id: "DCONSTRAINT", label: "Distance", - icon: IconKind::Glyph("↔"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/distance.svg")), event: ModuleEvent::Command("DCONSTRAINT".to_string()), } } @@ -69,7 +69,7 @@ pub mod angle_tool { ToolDef { id: "ACONSTRAINT", label: "Angle", - icon: IconKind::Glyph("∠"), + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/angle.svg")), event: ModuleEvent::Command("ACONSTRAINT".to_string()), } } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 2d0eee684..99bd2e6c3 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -1,6 +1,6 @@ // Shared value types used by the dispatch and grip systems. -use acadrust::types::{Color as AcadColor, LineWeight}; +use acadrust::types::{Color as AcadColor, Handle, LineWeight}; use glam::DVec3; /// The kind of value held by a property row. @@ -58,6 +58,28 @@ pub enum PropValue { /// an editable text_input. Unlike the other rows the routing key is the /// tag carried here, not the row's `&'static str` field. AttrText { tag: String, value: String }, + /// A clickable link to one or more entities — the Constraints section's + /// row for one persistent sketch constraint. Clicking it selects every + /// entity in `handles` in the viewport. `conflicting` mirrors + /// `SketchConstraintSet::conflicts`, tinting the row the same danger + /// color the constraint's glyph pill already uses. + EntityLink { handles: Vec, conflicting: bool }, + /// One row of the document-wide named-parameter table (Parameters + /// section, shown when nothing is selected). `index` is the row's + /// position in `ParameterTable::iter()` order — stable across edits to + /// other rows, used to route `PropParamInput`/`PropParamCommit`/ + /// `PropParamDelete` back to the right parameter without re-keying on a + /// name that might itself be mid-edit. `resolved` is the live value or + /// error, recomputed fresh every render (same "cheap enough to rebuild" + /// approach the old modal's preview column already used). + ParamRow { + index: usize, + name: String, + formula: String, + resolved: Result, + }, + /// The trailing "+ Add parameter" row in the Parameters section. + ParamAddRow, } /// A single property row in the Properties panel. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 42e6e8888..a98c7e119 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -735,7 +735,10 @@ impl Scene { PropValue::Stepper { .. } | PropValue::ColorVaries | PropValue::LwVaries - | PropValue::FieldLwVaries { .. } => continue, + | PropValue::FieldLwVaries { .. } + | PropValue::EntityLink { .. } + | PropValue::ParamRow { .. } + | PropValue::ParamAddRow => continue, } }; out.push(choice(prop.field, prop.label, editor)); @@ -883,7 +886,10 @@ impl Scene { PropValue::Stepper { display, .. } => display, PropValue::ColorVaries | PropValue::LwVaries - | PropValue::FieldLwVaries { .. } => return None, + | PropValue::FieldLwVaries { .. } + | PropValue::EntityLink { .. } + | PropValue::ParamRow { .. } + | PropValue::ParamAddRow => return None, }) } } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 9033adb99..376a0d8b1 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -267,6 +267,10 @@ pub enum FieldKey { Geom(&'static str), /// A block attribute value field, keyed by its tag. Attr(String), + /// One column of one named-parameter row, keyed by its stable + /// `ParameterTable::iter()` position (see `PropValue::ParamRow`'s doc + /// comment for why index rather than name). + Param(usize, crate::ui::window::named_parameters::ParamField), } impl FieldKey { @@ -277,10 +281,21 @@ impl FieldKey { match self { FieldKey::Geom(field) => prop_geom_field_id(field), FieldKey::Attr(tag) => prop_attr_field_id(tag), + FieldKey::Param(index, field) => prop_param_field_id(*index, *field), } } } +/// Text-input widget id for one column of a named-parameter row. +pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField) -> iced::widget::Id { + use crate::ui::window::named_parameters::ParamField; + let suffix = match field { + ParamField::Name => "name", + ParamField::Formula => "formula", + }; + iced::widget::Id::from(format!("props-param-field-{index}-{suffix}")) +} + /// Edit-buffer / active-field key for a block attribute value, keyed by its /// tag. Kept in one place so the live-input handler and the row renderer agree. pub fn attr_edit_key(tag: &str) -> FieldKey { @@ -338,6 +353,13 @@ pub fn build_field_key_map( if let Some(key) = key { map.insert(key.widget_id(), key); } + if let PropValue::ParamRow { index, .. } = &prop.value { + use crate::ui::window::named_parameters::ParamField; + for field in [ParamField::Name, ParamField::Formula] { + let key = FieldKey::Param(*index, field); + map.insert(key.widget_id(), key); + } + } } } map @@ -842,6 +864,13 @@ impl PropertiesPanel { self.render_hatch_pattern_row(label, current) } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), + PropValue::EntityLink { handles, conflicting } => { + render_entity_link_row(label, handles.clone(), *conflicting) + } + PropValue::ParamRow { index, name, formula, resolved } => { + self.render_param_row(*index, name, formula, resolved) + } + PropValue::ParamAddRow => render_param_add_row(), } } @@ -1367,6 +1396,96 @@ impl PropertiesPanel { prop_row_with_active(tag, ti.into(), active) } + /// One named-parameter row: editable name + formula, with the live- + /// resolved value (or error) shown alongside — the "AutoCAD style" the + /// Parameters section is modeled on, embedded in the panel instead of + /// the old separate modal. Each field commits on submit (Enter / losing + /// focus), not per keystroke — `ParameterTable::set` validates + /// immediately and would otherwise reject a formula mid-type (the same + /// reasoning the old modal's buffered-Apply design used, just applied + /// per-field instead of per-whole-table). + fn render_param_row<'a>( + &'a self, + index: usize, + name: &'a str, + formula: &'a str, + resolved: &'a Result, + ) -> Element<'a, Message> { + use crate::ui::window::named_parameters::ParamField; + + let name_key = FieldKey::Param(index, ParamField::Name); + let name_display = self.edit_buf.get(&name_key).map(|s| s.as_str()).unwrap_or(name); + let name_active = self.active_field.as_ref() == Some(&name_key); + let name_input = text_input("name", name_display) + .id(name_key.widget_id()) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Name }) + .size(FONT_SZ) + .style(text_input_style) + .padding([3, 6]) + .width(Length::FillPortion(3)); + + let formula_key = FieldKey::Param(index, ParamField::Formula); + let formula_display = self.edit_buf.get(&formula_key).map(|s| s.as_str()).unwrap_or(formula); + let formula_active = self.active_field.as_ref() == Some(&formula_key); + let formula_input = text_input("formula", formula_display) + .id(formula_key.widget_id()) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Formula }) + .size(FONT_SZ) + .style(text_input_style) + .padding([3, 6]) + .width(Length::FillPortion(3)); + + let (value_text, is_error) = match resolved { + Ok(v) => (format!("{v:.4}"), false), + Err(_) => ("—".to_string(), true), + }; + let value_label = container( + text(value_text).size(FONT_SZ).style(move |theme: &Theme| iced::widget::text::Style { + color: is_error.then_some(theme.palette().danger.base.color), + }), + ) + .width(Length::FillPortion(2)) + .align_x(iced::Right); + + let delete_btn = button(text("\u{2715}").size(FONT_SZ)) + .on_press(Message::PropParamDelete(index)) + .style(button::text) + .padding([2, 6]); + + let active = name_active || formula_active; + let bg = move |theme: &Theme| { + if active { + Background::Color(theme.palette().primary.weak.color) + } else { + Background::Color(theme.palette().background.base.color) + } + }; + let content = container(row![name_input, formula_input, value_label, delete_btn].spacing(4).align_y(iced::Center)) + .style(move |theme: &Theme| container::Style { background: Some(bg(theme)), ..Default::default() }) + .padding([2, 6]) + .width(Length::Fill); + + if let Err(err) = resolved { + tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top) + .gap(4.0) + .padding(6.0) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.danger.weak.color)), + text_color: Some(palette.danger.weak.text), + border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into() }, + ..Default::default() + } + }) + .into() + } else { + content.into() + } + } + fn render_hatch_pattern_row<'a>( &'a self, label: &'a str, @@ -1805,6 +1924,55 @@ fn render_ro_with_tooltip_row<'a>( prop_row_widget(label, wrapped.into()) } +// ── Constraints section row (clickable entity link) ─────────────────────── + +/// One persistent-constraint row in the Constraints properties section: +/// `label` is the glyph + kind name + resolved value (e.g. "↔ Distance: +/// hole_dia = 12.00"), rendered as the row's own clickable surface — there's +/// no natural separate "value" for a constraint row, unlike a Layer/Color +/// field, so this doesn't use the usual `prop_row_widget` label|value split. +/// Clicking it selects every entity in `handles`; a conflicting/redundant +/// constraint (mirrors the viewport glyph pill's own color cue) tints red. +fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: bool) -> Element<'a, Message> { + let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) + .on_press(Message::PropConstraintLinkClick(handles)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = if conflicting { + palette.danger.weak + } else { + match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, + } + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + text_color: pair.text, + ..Default::default() + } + }) + .padding([3, 8]) + .width(Length::Fill); + container(btn).width(Length::Fill).into() +} + +// ── Parameters section: "+ Add parameter" row ────────────────────────────── + +/// The trailing "+ Add parameter" row: appends a fresh, uniquely-named +/// parameter (`param1`, `param2`, …) the user then renames/redefines inline +/// — avoids a separate "pending new row" concept, since every row always +/// reflects a real committed table entry. +fn render_param_add_row<'a>() -> Element<'a, Message> { + let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center)) + .on_press(Message::PropParamAddNew) + .style(button::text) + .padding([4, 8]) + .width(Length::Fill); + container(btn).width(Length::Fill).into() +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index bfe494f02..1cff42bf9 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -88,7 +88,7 @@ fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> { } /// Which column of a parameter row a text edit targets. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ParamField { Name, Formula, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 1aacbcb93..a5ffa6a22 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -51,6 +51,7 @@ pub fn view_window<'a>( default_save_format: &'a str, file_assoc_enabled: bool, write_dwg_native_constraints: bool, + show_constraint_values: bool, ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], language: crate::i18n::Language, @@ -230,6 +231,23 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), + Space::new().height(14), + row![ + iced::widget::checkbox(show_constraint_values) + .on_toggle(Message::ShowConstraintValuesChanged) + .size(15), + text(crate::t!("Show values and parameter names on constraint markers")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "On by default. Turn off to show just the constraint glyph in the viewport — \ + enough to see that a constraint is present — without the driven value or named \ + parameter text covering nearby geometry." + )) + .size(11) + .width(sizing.width), ] .spacing(0) .width(sizing.width); From eca4e9d7a5a1c7051c37ea996d7a336eefa45e69 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Thu, 10 Sep 2026 23:16:37 +0200 Subject: [PATCH 14/99] Document Properties-panel constraints/parameters, pill toggle, icon redesign, and the ellipse solver fix in CHANGELOG Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b2298fd..6dfc789b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ pushed. ## Unreleased (since `3d0a41b6`) +- **Feature:** Properties panel gains a "Parameters" section (shown with no + selection) for inline named-parameter name/formula editing, add, and + delete, and a "Constraints" section (shown for a single selected entity) + listing every constraint touching it as a clickable link that selects + the linked entities in the viewport — danger-tinted when the constraint + conflicts. A newly-added parameter's formula now defaults to `1`, not + `0`, since a 0-length seed value is a genuine solver singularity for a + Distance constraint driven by that parameter (no defined direction to + grow back out of). + ([command_driver.rs](src/app/command_driver.rs), + [properties.rs](src/app/properties.rs), + [ui/properties.rs](src/ui/properties.rs)) + +- **Feature:** new Options > General toggle, "Show values and parameter + names on constraint markers" (on by default, persisted). Off, every + constraint pill in the viewport shows just its bare glyph instead of + glyph-plus-driven-value/parameter-name — the glyph alone is enough to + see a constraint is present, without the value text covering nearby + geometry on a dense sketch. + ([options.rs](src/ui/window/options.rs), + [view/mod.rs](src/app/view/mod.rs)) + +- **Feature:** all 18 constraint-ribbon icons (including the plain "F", + "L", "M", "S" letter glyphs) replaced with proper SVGs under + `assets/icons/constrain/`, matching the Modify section's two-color style + (`#B4B6B9` white / `#6DB7ED` blue, consistent stroke width). Tangent + reuses the Draw section's white-arc-plus-blue-dots motif. + ([assets/icons/constrain](assets/icons/constrain), + [tools.rs](src/modules/draw/constrain/tools.rs)) + +- **Fix:** a Concentric or CenterPoint constraint that moved an ellipse's + center visibly rotated and reshaped the ellipse, even though nothing + constrained its shape. Root cause: `ocs_gcs::geo::Ellipse` stores + `focus1` as an absolute point rather than an offset from `center`, so + when a constraint pulled only on `center`, `focus1`'s absolute + coordinates stayed put while `center` moved — changing the derived + `focus1 - center` vector that `major_axis`/`minor_axis_ratio` are + computed from on write-back. The solver now adds two constraints per + registered ellipse pinning that offset to its pre-solve value, so + `focus1` translates rigidly with `center` unless something actually + constrains it. + ([sketch_solve.rs](src/scene/sketch_solve.rs)) + - **Fix:** a sketch's constraint set (or the named-parameter table) silently lost *all* of its entries on the next DWG open once its serialized size passed 255 bytes — roughly 5-6 constraints, easily reached by a real From fe8397b3cb8b6564d190cd6c2bfbde1c41c386dc Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:07:30 +0200 Subject: [PATCH 15/99] remove ocs_doc_api crate and DocApi v2 host integration - Drop crates/ocs_doc_api from workspace and host dependency. - Remove src/app/doc_api.rs and its module declaration. - Strip DocApi v2 undo/dispatch plumbing from HostSession. - Remove HostApi::doc_api_dispatch default method. - Remove PluginRequest::DocApiRequest / PluginResponse::DocApiResponse. - Revert plugin API version from 6 back to 5. --- Cargo.lock | 15 - Cargo.toml | 2 - crates/ocs_doc_api/ARCHITECTURE.md | 45 - crates/ocs_doc_api/Cargo.toml | 66 - crates/ocs_doc_api/README.md | 139 - crates/ocs_doc_api/bindings/README.md | 59 - .../ocs_doc_api/bindings/python/generate.py | 185 - .../bindings/python/test_generate.py | 75 - crates/ocs_doc_api/build.rs | 452 -- crates/ocs_doc_api/examples/common/mod.rs | 710 --- .../examples/e1_primitives_and_queries.rs | 45 - .../examples/e2_intersect_two_solids.rs | 53 - .../examples/e3_transform_and_chain.rs | 38 - .../examples/e4_2d_entities_and_bulk.rs | 49 - .../examples/e5_failure_and_guards.rs | 60 - crates/ocs_doc_api/spec/entities.toml | 566 --- crates/ocs_doc_api/src/backend.rs | 313 -- crates/ocs_doc_api/src/convert.rs | 271 - crates/ocs_doc_api/src/envelope.rs | 93 - crates/ocs_doc_api/src/error.rs | 68 - crates/ocs_doc_api/src/executor.rs | 616 --- crates/ocs_doc_api/src/facade.rs | 1413 ------ crates/ocs_doc_api/src/gen/api_reference.md | 382 -- .../ocs_doc_api/src/gen/binding_schema.json | 1349 ----- crates/ocs_doc_api/src/gen/layouts.json | 63 - crates/ocs_doc_api/src/gen/ops_gen.rs | 127 - crates/ocs_doc_api/src/gen/query_gen.rs | 89 - crates/ocs_doc_api/src/geom.rs | 100 - crates/ocs_doc_api/src/id.rs | 36 - crates/ocs_doc_api/src/layer.rs | 42 - crates/ocs_doc_api/src/lib.rs | 75 - crates/ocs_doc_api/src/ops.rs | 489 -- crates/ocs_doc_api/src/query.rs | 115 - crates/ocs_doc_api/src/revision.rs | 16 - crates/ocs_doc_api/src/transport/inproc.rs | 42 - crates/ocs_doc_api/src/transport/ipc.rs | 199 - crates/ocs_doc_api/src/transport/mod.rs | 36 - crates/ocs_doc_api/src/validation.rs | 299 -- crates/ocs_doc_api/tests/mock_backend.rs | 1082 ---- .../tests/spec_wire_consistency.rs | 416 -- crates/ocs_plugin_api/build.rs | 2 +- crates/ocs_plugin_api/src/host.rs | 12 - crates/ocs_plugin_api/src/ipc/protocol.rs | 10 - crates/ocs_plugin_api/src/ipc/server.rs | 4 - crates/ocs_plugin_api/src/manifest.rs | 12 +- src/app/doc_api.rs | 4501 ----------------- src/app/mod.rs | 2 - src/app/plugin_host.rs | 40 - 48 files changed, 4 insertions(+), 14869 deletions(-) delete mode 100644 crates/ocs_doc_api/ARCHITECTURE.md delete mode 100644 crates/ocs_doc_api/Cargo.toml delete mode 100644 crates/ocs_doc_api/README.md delete mode 100644 crates/ocs_doc_api/bindings/README.md delete mode 100644 crates/ocs_doc_api/bindings/python/generate.py delete mode 100644 crates/ocs_doc_api/bindings/python/test_generate.py delete mode 100644 crates/ocs_doc_api/build.rs delete mode 100644 crates/ocs_doc_api/examples/common/mod.rs delete mode 100644 crates/ocs_doc_api/examples/e1_primitives_and_queries.rs delete mode 100644 crates/ocs_doc_api/examples/e2_intersect_two_solids.rs delete mode 100644 crates/ocs_doc_api/examples/e3_transform_and_chain.rs delete mode 100644 crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs delete mode 100644 crates/ocs_doc_api/examples/e5_failure_and_guards.rs delete mode 100644 crates/ocs_doc_api/spec/entities.toml delete mode 100644 crates/ocs_doc_api/src/backend.rs delete mode 100644 crates/ocs_doc_api/src/convert.rs delete mode 100644 crates/ocs_doc_api/src/envelope.rs delete mode 100644 crates/ocs_doc_api/src/error.rs delete mode 100644 crates/ocs_doc_api/src/executor.rs delete mode 100644 crates/ocs_doc_api/src/facade.rs delete mode 100644 crates/ocs_doc_api/src/gen/api_reference.md delete mode 100644 crates/ocs_doc_api/src/gen/binding_schema.json delete mode 100644 crates/ocs_doc_api/src/gen/layouts.json delete mode 100644 crates/ocs_doc_api/src/gen/ops_gen.rs delete mode 100644 crates/ocs_doc_api/src/gen/query_gen.rs delete mode 100644 crates/ocs_doc_api/src/geom.rs delete mode 100644 crates/ocs_doc_api/src/id.rs delete mode 100644 crates/ocs_doc_api/src/layer.rs delete mode 100644 crates/ocs_doc_api/src/lib.rs delete mode 100644 crates/ocs_doc_api/src/ops.rs delete mode 100644 crates/ocs_doc_api/src/query.rs delete mode 100644 crates/ocs_doc_api/src/revision.rs delete mode 100644 crates/ocs_doc_api/src/transport/inproc.rs delete mode 100644 crates/ocs_doc_api/src/transport/ipc.rs delete mode 100644 crates/ocs_doc_api/src/transport/mod.rs delete mode 100644 crates/ocs_doc_api/src/validation.rs delete mode 100644 crates/ocs_doc_api/tests/mock_backend.rs delete mode 100644 crates/ocs_doc_api/tests/spec_wire_consistency.rs delete mode 100644 src/app/doc_api.rs diff --git a/Cargo.lock b/Cargo.lock index 9acbdfdf0..03db6242c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,6 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "ocs_doc_api", "ocs_plugin_api", "open", "printpdf", @@ -3891,20 +3890,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "ocs_doc_api" -version = "0.1.0" -dependencies = [ - "acadrust", - "bincode", - "cadkernel", - "ocs_plugin_api", - "serde", - "serde_json", - "thiserror 1.0.69", - "toml 1.1.3+spec-1.1.0", -] - [[package]] name = "ocs_plugin_api" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0a526489a..1d03b1311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "crates/ocs_web_worker", "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", - "crates/ocs_doc_api", ] [target.'cfg(windows)'.build-dependencies] @@ -76,7 +75,6 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } -ocs_doc_api = { path = "crates/ocs_doc_api", features = ["host", "ipc"] } bincode = "1.3" meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md deleted file mode 100644 index 8603f8a10..000000000 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ /dev/null @@ -1,45 +0,0 @@ -# Document API architecture - -`DocApi → Document → collections → typed handles` builds DTO requests. -`Transport::apply` carries one operation or a read-only query batch to the -executor. The native adapter binds each request to its existing `HostSession`. - -For out-of-process / worker-thread plugins, `doc_api_for_host(host)` constructs a -`DocApi` backed by `OcsPluginApiIpc`, which sends `PluginRequest::DocApiRequest` -through the host's `PluginRequestSender` and deserializes -`PluginResponse::DocApiResponse`. This keeps plugin code free of manual envelope -serialization while reusing the same executor that the in-process transport uses. - -The executor validates inputs and dispatches through `DocApiBackend`. Backends -must prepare fallible geometry and serialization before changing the document. -Bulk writes prepare all entities, begin one undo record, apply the prepared -changes, then finalize once. Cancellation releases a pending undo capture; -it is not a rollback mechanism for partially applied writes. - -The native adapter reuses scene changes, document history, entity transforms -and profile conversion. Solid construction, sweeps, booleans, meshing and mass -properties belong to cadkernel. Profile planes preserve their source elevation -and orientation. Geometry revisions advance when the scene changes; a compound -operation may advance the revision more than once while recording one undo step. - -`Operation` and `Query` are manually maintained wire enums. Append variants; -do not reorder them. The consistency tests verify their bincode discriminants. -`spec/entities.toml` supplies family, constructor and method mappings. `build.rs` -generates the API reference and binding schema. The layout snapshot is curated -vocabulary, not a reflected binary codec schema. - -Layer CRUD is a document-table family, not an entity family. The API carries -layer properties in the plain-data `LayerInfo` DTO and the host translates them -to/from `acadrust::tables::Layer`. Layer names are case-insensitive. The host -rejects duplicate names, deletion of layer "0" or the current layer, and removal -of a layer that still has entities assigned. - -XDATA and XRECORD are serialization roundtrip families: the API carries their -payloads as plain-data DTOs and the host stores/retrieves them without semantic -interpretation. XDATA records are keyed by registered application name on any -entity; XRECORD objects are standalone named dictionaries of group-code/value -entries. Both preserve exact bytes/values across save and reload. - -To extend the API, update the DTO, backend, executor, facade and spec together, -add a native behavior test, and rebuild the generated reference/schema. New -backend methods must reject unsupported behavior explicitly. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml deleted file mode 100644 index 744d3559e..000000000 --- a/crates/ocs_doc_api/Cargo.toml +++ /dev/null @@ -1,66 +0,0 @@ -[package] -name = "ocs_doc_api" -version = "0.1.0" -edition = "2021" -description = "Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and hosts (v2)." -license = "MIT" -build = "build.rs" - -# Dependency wiring: pin the SAME cadkernel/acadrust git revs -# as the host so `Body`/`EntityType` types are ABI-identical. -[dependencies] -serde = { version = "1", features = ["derive"] } -bincode = "1.3" -thiserror = "1" - -# Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"], optional = true } -ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } - -[build-dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "1" - -[dev-dependencies] -serde_json = "1" -toml = "1" - -[features] -# Default keeps the facade and DTOs independent of geometry dependencies. -default = [] -# host: pull acadrust + cadkernel + the executor/backend trait (in-process side). -host = ["acadrust", "cadkernel"] -# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). -ipc = ["acadrust", "ocs_plugin_api"] -# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only). -doc_api_host = ["ipc"] - -[lib] -name = "ocs_doc_api" -path = "src/lib.rs" - -[[test]] -name = "mock_backend" -required-features = ["host"] - -[[example]] -name = "e1_primitives_and_queries" -required-features = ["host"] - -[[example]] -name = "e2_intersect_two_solids" -required-features = ["host"] - -[[example]] -name = "e3_transform_and_chain" -required-features = ["host"] - -[[example]] -name = "e4_2d_entities_and_bulk" -required-features = ["host"] - -[[example]] -name = "e5_failure_and_guards" -required-features = ["host"] diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md deleted file mode 100644 index cc38a56ca..000000000 --- a/crates/ocs_doc_api/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# ocs_doc_api - -Typed document operations and queries for OpenCADStudio plugins and hosts. -The same facade runs over an in-process backend or the plugin IPC channel. - -```rust -use ocs_doc_api::{ApiResult, DocApi}; - -fn intersect_solids(api: &DocApi) -> ApiResult { - let doc = api.document(api.active_tab()); - let a = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - a.intersect(&b)?.volume() -} -``` - -Each successful write marks the document changed and records one undo step. -A failed operation leaves the document unchanged. Bulk creation and transforms -prepare every entity before applying changes. Separate calls commit separately; -`OpGroup` offers best-effort cleanup of creations, not rollback. - -Boolean `intersect`, `union` and `subtract` replace the first solid and erase the -second. `intersects` only tests bounding-box overlap. Volume and centroid are -computed by the kernel from a tessellated solid (chord tolerance 0.1 drawing units, -angular tolerance 0.0001 radians), then cached per geometry revision. A request -may compute at most 32 uncached mass properties; excess work returns an error. -Bulk operations and query batches are capped at 100,000 items. - -Transports bind to one document tab. Typed handles from another transport and -requests for another tab are rejected. Raw `ObjectId` values are relative to the -receiving document. Errors are serialized as `ApiError` variants over IPC. - -Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`, -`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` / -`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`, -`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO -with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules: -layer "0" and the current layer cannot be deleted, duplicate names are rejected, -and a layer still referenced by entities cannot be removed. - -XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs -(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped -verbatim by the host. Every `Entity` handle can read/write one XDATA record per -registered application name via `xdata(application_name)` and `set_xdata(...)`, -and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)` -and updated through the `XRecord` handle. - -| Feature | Use | -|---|---| -| Default | DTOs, facade, transport trait and binding schema | -| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | -| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | -| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option` for out-of-process / worker-thread plugins | - -### Connecting from a plugin - -For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any -`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature -and use the helper instead of manually serializing `DocApiEnvelope`s: - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - Some(()) -} -``` - -`doc_api_for_host` returns `None` for hosts that do not expose a -`PluginRequestSender` (for example the in-process `HostSession`). In-process code -should use `DocApi::in_process(backend, tab_id)` with a concrete -`DocApiBackend`. - -### Plugin example: create, read, update and delete a line - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight}; -use ocs_plugin_api::host::HostApi; - -fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> { - // Build a DocApi from the host's plugin request channel. - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - // Ensure a target layer exists. - let mut info = LayerInfo::new("MyLayer"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - let _ = doc.create_layer(&info); // ok if it already exists - - // Create a line on that layer. - let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - line.set_layer("MyLayer")?; - - // Read it back. - assert_eq!(line.layer()?, "MYLAYER"); - - // Move the endpoint in place (same ObjectId). - line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?; - - // Delete it; the document is left unchanged if this failed. - line.delete()?; - Ok(()) -} -``` - -### Plugin example: create a box and read its volume - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult}; -use ocs_plugin_api::host::HostApi; - -fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult { - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let volume = box_.volume()?; - - // Boolean example: intersect with a second box. - let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - let intersection = box_.intersect(&other)?; - let _intersection_volume = intersection.volume()?; - - Ok(volume) -} -``` - -See the [API reference](src/gen/api_reference.md) for constructors and methods, -[architecture](ARCHITECTURE.md) for backend rules, and -[binding guide](bindings/README.md) for the Python facade and transport contract. diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md deleted file mode 100644 index deb13c55d..000000000 --- a/crates/ocs_doc_api/bindings/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Language bindings - -`src/gen/binding_schema.json` describes handle families, constructors, methods, -arguments and operation/query mappings. `build.rs` derives it from -`spec/entities.toml`. Its `layouts` section is manually maintained vocabulary; -it does not contain everything needed to generate a bincode codec. - -Generate and test the Python facade from the repository root: - -```sh -python3 crates/ocs_doc_api/bindings/python/generate.py > /tmp/ocs_doc.py -python3 -m unittest discover -s crates/ocs_doc_api/bindings/python -``` - -The generated `DocApi(transport, active_tab)` expects a caller-supplied -`transport.apply(envelope) -> Receipt`. It does not open a plugin connection. -An envelope has `version` and a Python tuple `("Op", operation_dict)` or -`("Queries", [query_dict])`. Enum payloads use externally tagged dictionaries, -for example `{"CreateSolid": {"Sphere": {"centre": [0, 0, 0], "radius": 2}}}`. -The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format, -sends `DocApiRequest { tab_id, bytes }`, and decodes `Result`. -Host plugin API version 6 and document envelope version 1 are required. - -For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature, -use the `doc_api_for_host` helper to build a typed `DocApi` from any -`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender` -(out-of-process / worker-thread hosts): - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - // ... - Some(()) -} -``` - -In-process plugins should use `DocApi::in_process(backend, tab_id)` with a -concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not -expose a `PluginRequestSender`. - -Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query -results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade -unwraps these into typed handles or values. Layer results use `{"Layers": [...]}` -and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`, -`Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g. -`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false, -"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7}, -"line_type": "Continuous", "line_weight": {"Default": null}, -"plot_style": "", "is_plottable": true}}`. -XDATA and XRECORD payloads appear as nested externally tagged dictionaries -(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or -`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an -exception for an error response. Transport binding determines the tab; requesting -another tab or combining typed handles from different documents raises an error. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py deleted file mode 100644 index d361493ba..000000000 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a Python facade over a caller-supplied document transport. - -Usage: python generate.py ../../src/gen/binding_schema.json > ocs_doc.py -The transport converts these Python DTOs to/from the Rust wire representation. -""" -import json -import sys -from pathlib import Path - -HEADER = '''"""Generated document facade. See bindings/README.md for the transport contract.""" -from dataclasses import dataclass -from typing import Any - -@dataclass -class DocApiEnvelope: - version: int - body: Any - -@dataclass -class Receipt: - outcome: Any - query_results: list - new_revision: int - -@dataclass -class Entity: - id: int - _doc: "Document" - - def bounds(self): - return self._doc._apply_query({"GetBounds": {"id": self.id}}) - - def transform(self, placement): - self._doc._apply_op({"Transform": {"id": self.id, "placement": placement}}) - - def delete(self): - self._doc._apply_op({"Delete": {"id": self.id}}) - - def layer(self): - return self._doc._apply_query({"GetEntityLayer": {"id": self.id}}) - - def set_layer(self, layer): - self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}}) -''' - -DOCUMENT = ''' -@dataclass -class Document: - _transport: Any - _tab: int - - def _id(self, value): - if isinstance(value, Entity): - if value._doc._transport is not self._transport or value._doc._tab != self._tab: - raise ValueError("handle belongs to another document") - return value.id - return int(value) - - def _apply_op(self, op, want=None): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Op", op))) - if want is None: - return None - if want.startswith("Vec<"): - handle = globals()[want[4:-1]] - return [handle(i, self) for i in receipt.outcome["NewIds"]] - return globals()[want](receipt.outcome["NewId"], self) - - def _apply_query(self, query): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [query]))) - if len(receipt.query_results) != 1: - raise ValueError("expected one query result") - result = receipt.query_results[0] - if not isinstance(result, dict) or len(result) != 1: - raise ValueError("invalid query result") - return next(iter(result.values())) - - def revision(self): - return self._apply_query("GetGeometryRevision") - - def block_entities(self, block_name): - return self._apply_query({"GetBlockEntities": {"block_name": block_name}}) - - def solids(self): return Solids(self) - def curves(self): return Curves(self) - def entities(self): return Entities(self) - - def layers(self): - return self._apply_query({"ListLayers": {}}) - - def create_layer(self, info): - self._apply_op({"CreateLayer": info}) - - def update_layer(self, name, info): - self._apply_op({"UpdateLayer": {"name": name, "info": info}}) - - def delete_layer(self, name): - self._apply_op({"DeleteLayer": {"name": name}}) - -@dataclass -class DocApi: - _transport: Any - active_tab: int = 0 - - def document(self, tab): - if tab != self.active_tab: - raise ValueError("transport is bound to another tab") - return Document(self._transport, tab) -''' - - -def render_method(method, constructor=False): - args = method.get("args", []) - signature = "".join(", " + a["name"] for a in args) - variant = method.get("op") or method["query"] - fields = {} - for arg in args: - name, ty = arg["name"], arg["ty"] - if ty in ("EntityRef", "Solid", "ObjectId"): - fields[name] = f"self._doc._id({name})" - elif ty == "&[EntityRef]": - fields[name] = f"[self._doc._id(p) for p in {name}]" - else: - fields[name] = name - fixed = dict(method.get("fixed") or {}) - if variant in ("SolidBoolean", "GetIntersects"): - fields = {"a": "self.id", "b": fields.pop("other"), **fields} - fields.pop("other", None) - elif not constructor: - fields = {"id": "self.id", **fields} - if variant == "Revolve": - fields["axis"] = "(pivot, axis)" - fields.pop("pivot") - nested = fixed.pop("primitive", fixed.pop("curve", None)) - fields.update({key: repr(value) for key, value in fixed.items()}) - payload = "{" + ", ".join(repr(k) + ": " + v for k, v in fields.items()) + "}" - if variant == "CreateMany": - payload = "[{'Curve': {'Point': {'position': p}}} for p in positions]" - elif nested: - payload = "{" + repr(nested) + ": " + payload + "}" - message = "{" + repr(variant) + ": " + payload + "}" - if method.get("kind") == "query": - call = f"self._doc._apply_query({message})" - else: - want = method.get("returns") - if want == "()": - want = None - call = f"self._doc._apply_op({message}, {want!r})" - return f" def {method['name']}(self{signature}):\n return {call}\n" - - -def render(schema): - out = [HEADER, f"ENVELOPE_VERSION = {schema['envelope_version']}\n"] - handles = {name: {} for name in schema["object_model"]["handles"]} - collections = {"solids": {}, "curves": {}, "entities": {}} - for family in schema["families"]: - handle = family.get("handle") or family["name"] - for method in family.get("methods", []): - existing = handles[handle].get(method["name"]) - if existing is not None and existing != method: - raise ValueError(f"conflicting method: {handle}.{method['name']}") - handles[handle][method["name"]] = method - for constructor in family.get("constructors", []): - collections[family["collection"]][constructor["name"]] = constructor - # Families sharing Entity contribute to one class, preserving all methods. - entity_methods = "\n".join(render_method(m) for m in handles.pop("Entity").values()) - out[0] += entity_methods - for handle, methods in handles.items(): - out.append(f"\nclass {handle}(Entity):\n" + ( - "\n".join(render_method(m) for m in methods.values()) or " pass\n")) - for collection, constructors in collections.items(): - out.append(f"\nclass {collection.title()}:\n def __init__(self, doc):\n self._doc = doc\n") - out.extend(render_method(c, constructor=True) for c in constructors.values()) - out.append(DOCUMENT) - return "\n".join(out) - - -def main(): - default = Path(__file__).resolve().parents[2] / "src/gen/binding_schema.json" - path = Path(sys.argv[1]) if len(sys.argv) > 1 else default - sys.stdout.write(render(json.loads(path.read_text(encoding="utf-8")))) - - -if __name__ == "__main__": - main() diff --git a/crates/ocs_doc_api/bindings/python/test_generate.py b/crates/ocs_doc_api/bindings/python/test_generate.py deleted file mode 100644 index b63ed1fe8..000000000 --- a/crates/ocs_doc_api/bindings/python/test_generate.py +++ /dev/null @@ -1,75 +0,0 @@ -import inspect -import json -from pathlib import Path -import types -import sys -import unittest - -from generate import render - - -class BindingTest(unittest.TestCase): - def setUp(self): - self.schema = json.loads((Path(__file__).resolve().parents[2] / 'src/gen/binding_schema.json').read_text()) - self.module = types.ModuleType('generated') - sys.modules['generated'] = self.module - self.addCleanup(sys.modules.pop, 'generated') - exec(render(self.schema), self.module.__dict__) - module = self.module - - class Transport: - def __init__(self): - self.calls = [] - - def apply(self, envelope): - self.calls.append(envelope) - kind, payload = envelope.body - if kind == 'Queries': - return module.Receipt(None, [{'Volume': 125.0}], 2) - if 'CreateMany' in payload: - return module.Receipt({'NewIds': [10, 11]}, [], 2) - return module.Receipt({'NewId': 9}, [], 2) - - self.transport = Transport() - self.api = module.DocApi(self.transport, active_tab=4) - self.doc = self.api.document(4) - - def test_constructors_returns_and_wire_payloads(self): - solid = self.doc.solids().create_cuboid([0, 0, 0], [5, 5, 5]) - self.assertIsInstance(solid, self.module.Solid) - self.assertEqual(self.transport.calls[-1].body, ('Op', {'CreateSolid': {'Cuboid': {'origin': [0, 0, 0], 'size': [5, 5, 5]}}})) - self.assertEqual(solid.volume(), 125.0) - self.assertIsInstance(solid.intersect(solid), self.module.Solid) - self.assertEqual(self.transport.calls[-1].body[1], {'SolidBoolean': {'a': 9, 'b': 9, 'op': 'Intersection', 'erase_sources': True}}) - points = self.doc.curves().create_points([[0, 0, 0], [1, 1, 1]]) - self.assertEqual([p.id for p in points], [10, 11]) - self.assertTrue(all(isinstance(p, self.module.Point) for p in points)) - self.doc.solids().revolve(points[0], [0, 0, 0], [0, 0, 1], 3.14) - self.assertEqual(self.transport.calls[-1].body[1]['Revolve']['axis'], ([0, 0, 0], [0, 0, 1])) - self.doc.solids().loft(points) - self.assertEqual(self.transport.calls[-1].body[1], {'Loft': {'profiles': [10, 11]}}) - self.assertIsNone(points[0].delete()) - - def test_every_schema_method_and_constructor_survives_generation(self): - for family in self.schema['families']: - handle = getattr(self.module, family['handle']) - collection = getattr(self.doc, family['collection'])() - for method in family['methods']: - self.assertTrue(callable(getattr(handle, method['name']))) - for constructor in family['constructors']: - method = getattr(collection, constructor['name']) - self.assertEqual(list(inspect.signature(method).parameters), [a['name'] for a in constructor['args']]) - - def test_foreign_handles_and_tab_mismatch_do_not_send(self): - with self.assertRaises(ValueError): - self.api.document(5) - foreign = self.module.Solid(9, self.module.DocApi(object(), 4).document(4)) - local = self.module.Solid(9, self.doc) - for operation in [lambda: local.union(foreign), lambda: self.doc.solids().extrude(foreign, [0, 0, 1]), lambda: self.doc.solids().loft([foreign])]: - with self.assertRaises(ValueError): - operation() - self.assertEqual(self.transport.calls, []) - - -if __name__ == '__main__': - unittest.main() diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs deleted file mode 100644 index c951f0d66..000000000 --- a/crates/ocs_doc_api/build.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! Regenerate the API reference and binding schema from the curated spec. - -use std::env; -use std::fs; -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct Spec { - meta: Meta, - #[serde(default)] - family: Vec, - #[serde(default)] - kernel_map: Vec, -} - -#[derive(Deserialize)] -struct Meta { - envelope_version: u16, -} - -#[derive(Deserialize)] -struct Family { - name: String, - #[serde(default)] - acadrust_variant: Option, - #[serde(default)] - handle: Option, - #[serde(default)] - collection: Option, - #[serde(default)] - description: Option, - #[serde(default)] - constructor: Vec, - #[serde(default)] - method: Vec, -} - -#[derive(Deserialize)] -// `op`/`fixed` are part of the spec schema (used by binding_schema consumers and -// future codegen) even though api_reference generation doesn't read them yet. -#[allow(dead_code)] -struct Constructor { - name: String, - #[serde(default)] - args: Vec, - #[serde(default)] - op: Option, - #[serde(default)] - fixed: Option, - #[serde(default)] - bulk: bool, - #[serde(default)] - returns: Option, - #[serde(default)] - status: Option, -} - -#[derive(Deserialize)] -struct Method { - name: String, - kind: String, // "op" | "query" - #[serde(default)] - op: Option, - #[serde(default)] - query: Option, - #[serde(default)] - args: Vec, - #[serde(default)] - returns: Option, - #[serde(default)] - fixed: Option, - #[serde(default)] - status: Option, -} - -#[derive(Deserialize)] -struct Arg { - name: String, - ty: String, -} - -#[derive(Deserialize)] -struct KernelMap { - op: String, - kernel: String, - returns: String, -} - -fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let spec_path = manifest_dir.join("spec/entities.toml"); - println!("cargo:rerun-if-changed={}", spec_path.display()); - - println!("cargo:rerun-if-changed=src/gen/layouts.json"); - let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml"); - let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml"); - - let gen_dir = manifest_dir.join("src/gen"); - fs::create_dir_all(&gen_dir).unwrap(); - - write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec)); - - write_if_changed( - &gen_dir.join("binding_schema.json"), - &binding_schema_json(&spec), - ); -} - -/// Rewrite a generated file only when content changed (keeps mtime/CI stable). -fn write_if_changed(path: &PathBuf, content: &str) { - let unchanged = fs::read_to_string(path) - .map(|old| old == content) - .unwrap_or(false); - if !unchanged { - fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); - } -} - -/// Render a method/constructor's `fixed` fields compactly (e.g. -/// `, op="Intersection", erase_sources=true`). Empty when no fixed fields. -fn fmt_fixed(fixed: Option<&toml::Value>) -> String { - let Some(fixed) = fixed else { - return String::new(); - }; - let Some(tbl) = fixed.as_table() else { - return String::new(); - }; - if tbl.is_empty() { - return String::new(); - } - let parts: Vec = tbl.iter().map(|(k, v)| format!("{k}={v}")).collect(); - format!("; fixed {}", parts.join(", ")) -} - -fn fmt_args(args: &[Arg]) -> String { - // Renders `(...)` with the args, or `()` for a zero-arg method. - if args.is_empty() { - return String::new(); - } - args.iter() - .map(|a| format!("{}: {}", a.name, a.ty)) - .collect::>() - .join(", ") -} - -/// The parenthesised argument list: `(a: T, b: U)` or `()` for zero args. -fn fmt_call_args(args: &[Arg]) -> String { - if args.is_empty() { - return "()".to_string(); - } - format!("({})", fmt_args(args)) -} - -/// The generic handle methods + cross-cutting surface (applies to every handle; -/// documented once here rather than per-family). -fn generic_reference_md() -> String { - r#"## Generic methods (every handle) - -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, -`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a -`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - -- **`id()` -> `ObjectId`** — the stable entity identity. -- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## Collections & cross-cutting - -- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. -- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, - `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only - batch, one round-trip, **no revision bump**). -- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, - `transform_many(&ids, placement)`, `delete_many(&ids)`. -- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / - `compensate`). **Not** a transaction: no atomicity, no rollback. - -## Bulk ops — single op, all-or-nothing, item-capped - -`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo -step, one publish, and **all-or-nothing via upfront validation** (no rollback). -Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for -bulk data (N ≳ a few hundred); per-op autocommit is the right tool for -modeling-scale work. - -## Semantics - -- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host - call = one undo step. Multi-op write transactions were removed (security/ - integrity review). -- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a - failed op applies nothing and records no undo step. `erase_sources` booleans run - only on success. -- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, - Transport}`; bulk validation names the failing index; a stale handle returns - `UnknownId` (never a panic). - -"# - .to_string() -} - -fn status_suffix(status: Option<&str>) -> &'static str { - match status { - Some("unsupported") => " — **not yet supported** (returns `ApiError::Unsupported`)", - Some("planned") => " — **planned** (no callable surface yet)", - _ => "", - } -} - -fn api_reference_md(spec: &Spec) -> String { - let mut s = String::new(); - s.push_str("# ocs_doc_api — API reference (auto-generated)\n\n"); - s.push_str(&format!( - "Envelope version: `{}`\n\n", - spec.meta.envelope_version - )); - s.push_str("Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.\n\n"); - - for fam in &spec.family { - let collection = fam.collection.as_deref().unwrap_or("-"); - let handle = fam.handle.as_deref().unwrap_or(&fam.name); - s.push_str(&format!( - "## `{}` (acadrust `{}`, collection `{}`)\n\n", - handle, - fam.acadrust_variant.as_deref().unwrap_or("-"), - collection - )); - if let Some(d) = &fam.description { - s.push_str(&format!("{d}\n\n")); - } - - if !fam.constructor.is_empty() { - s.push_str(&format!("### Constructors (`doc.{collection}()`)\n\n")); - for c in &fam.constructor { - let ret = c.returns.as_deref().unwrap_or(handle); - let bulk = if c.bulk { " *(bulk op)*" } else { "" }; - let op = c.op.as_deref().unwrap_or("-"); - let fixed = fmt_fixed(c.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{ret}`{bulk} — `{op}`{fixed}{}\n", - c.name, - fmt_call_args(&c.args), - status_suffix(c.status.as_deref()) - )); - } - s.push('\n'); - } - - if !fam.method.is_empty() { - s.push_str("### Methods\n\n"); - for m in &fam.method { - let target = m.op.as_deref().or(m.query.as_deref()).unwrap_or("-"); - let fixed = fmt_fixed(m.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{}` — {} `{}`{fixed}{}\n", - m.name, - fmt_call_args(&m.args), - m.returns.as_deref().unwrap_or("()"), - m.kind, - target, - status_suffix(m.status.as_deref()) - )); - } - s.push('\n'); - } - } - - s.push_str(&generic_reference_md()); - - s.push_str("## Kernel mapping\n\n| op | kernel call | returns |\n|---|---|---|\n"); - for k in &spec.kernel_map { - s.push_str(&format!("| {} | `{}` | {} |\n", k.op, k.kernel, k.returns)); - } - s.push('\n'); - s.push_str(&wire_vocabulary_md()); - s.push('\n'); - s.push_str(&error_and_envelope_md()); - s -} - -/// The wire vocabulary section: every `Operation`/`Query` variant with its payload, -/// read from `src/gen/layouts.json` (the curated wire-layout snapshot). -fn wire_vocabulary_md() -> String { - let layouts = read_layouts(); - let mut s = String::from("## Wire vocabulary\n\nEvery `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch.\n\n"); - s.push_str("### `Operation`\n\n"); - if let Some(variants) = layouts - .get("Operation") - .and_then(|o| o.get("enum")) - .and_then(|v| v.as_array()) - { - for v in variants { - if let Some(name) = v.as_str() { - s.push_str(&format!("- `{name}`\n")); - } - } - } - s.push_str("\n### `Query`\n\n"); - if let Some(variants) = layouts - .get("Query") - .and_then(|o| o.get("enum")) - .and_then(|v| v.as_array()) - { - for v in variants { - if let Some(name) = v.as_str() { - s.push_str(&format!("- `{name}`\n")); - } - } - } - s -} - -/// The error model + envelope/transport section. -fn error_and_envelope_md() -> String { - r#"## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `Transport(String)` | The channel failed (disconnected / oversized / timeout). | - -`ApiError` is serializable, so IPC returns the same structured error the in-process -executor produced. - -## Envelope & transport - -`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or -`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's -envelope version (bridges must check it). Transports: `InProcess` (host) and -`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: -Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries -`outcome` (per-op result), `query_results`, and `new_revision`. -"# - .to_string() -} - -/// The merged, self-contained binding handover schema (§10.2). Object model + -/// method signatures + op/query mapping + curated wire vocabulary inlined per node. -/// The handle types for the binding schema, derived from the spec's families -/// ("Entity" always first, then the deduped family handles in spec order). -fn handles_for_schema(spec: &Spec) -> Vec { - let mut out = vec!["Entity".to_string()]; - for fam in &spec.family { - let h = fam.handle.as_deref().unwrap_or(&fam.name); - if h != "Entity" && !out.iter().any(|x| x == h) { - out.push(h.to_string()); - } - } - out -} - -fn binding_schema_json(spec: &Spec) -> String { - let registry = read_layouts(); - - let families: Vec = spec - .family - .iter() - .map(|fam| { - let methods: Vec = fam - .method - .iter() - .map(|m| { - serde_json::json!({ - "name": m.name, - "kind": m.kind, - "op": m.op, - "query": m.query, - "args": m.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>(), - "returns": m.returns, - "bulk": m.op.as_deref() == Some("CreateMany"), - "read_only": m.kind == "query", - "fixed": m.fixed, - }) - }) - .collect(); - serde_json::json!({ - "name": fam.name, - "handle": fam.handle, - "collection": fam.collection, - "acadrust_variant": fam.acadrust_variant, - "methods": methods, - "constructors": fam.constructor.iter().map(|c| serde_json::json!({ - "name": c.name, "op": c.op, "fixed": c.fixed, "bulk": c.bulk, - "returns": c.returns.as_deref().unwrap_or(fam.handle.as_deref().unwrap_or(&fam.name)), - "args": c.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>() - })).collect::>(), - }) - }) - .collect(); - - let schema = serde_json::json!({ - "schema": "ocs_doc_api/binding_schema", - "envelope_version": spec.meta.envelope_version, - "object_model": { - "root": "DocApi", - "document": "Document", - "collections": ["solids", "curves", "entities"], - // Derived from the spec's family handles (deduped, "Entity" first) so - // new handle families appear in the handover automatically. - "handles": handles_for_schema(spec), - }, - "families": families, - // Curated wire vocabulary (not a complete binary codec schema). - "layouts": registry, - }); - - serde_json::to_string_pretty(&schema).expect("serialize binding schema") -} - -/// Read the manually maintained wire vocabulary; malformed snapshots fail the build. -fn read_layouts() -> serde_json::Value { - let src = fs::read_to_string(layouts_snapshot_path()).expect("read layouts.json"); - serde_json::from_str(&src).expect("parse layouts.json") -} - -fn layouts_snapshot_path() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/gen/layouts.json") -} diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs deleted file mode 100644 index 8b7b7454b..000000000 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ /dev/null @@ -1,710 +0,0 @@ -//! Shared in-memory `DocApiBackend` for the runnable examples. -//! -//! Lets the examples run without the host binary: an in-process mock document -//! (HashMap + a kind table) behind the `InProcess` transport. -//! Not for production use — the real backend lives in the host (`src/app/doc_api.rs`). - -use std::collections::HashMap; - -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, - GeometryRevision, LayerInfo, ObjectId, PlacementSpec, -}; - - -#[derive(Clone)] -pub struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Stored 2D curve specs so `bounds()` works on non-solid entities too. - curves: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, - /// Stored layer table. - layers: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - curves: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - layers: HashMap::new(), - entity_layers: HashMap::new(), - }; - backend.layers.insert("0".to_string(), LayerInfo::new("0")); - backend - } -} - -impl MockBackend { - fn alloc(&mut self, kind: &str) -> ObjectId { - self.next_id += 1; - let id = ObjectId::from_u64(self.next_id); - self.kinds.insert(id, kind.to_string()); - id - } - fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { - self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) - } - #[allow(dead_code)] - pub fn undo_steps(&self) -> u32 { - self.undo_steps - } -} - -/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). -fn curve_bounds(spec: &Curve2Spec) -> Aabb { - match spec { - Curve2Spec::Line { start, end, .. } => Aabb { - min: [ - start[0].min(end[0]), - start[1].min(end[1]), - start[2].min(end[2]), - ], - max: [ - start[0].max(end[0]), - start[1].max(end[1]), - start[2].max(end[2]), - ], - }, - Curve2Spec::Circle { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Point { position, .. } => Aabb { - min: *position, - max: *position, - }, - Curve2Spec::Polyline { points, .. } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Arc { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Ellipse { - centre, major_axis, .. - } => { - let r = (major_axis[0] * major_axis[0] - + major_axis[1] * major_axis[1] - + major_axis[2] * major_axis[2]) - .sqrt(); - Aabb { - min: [centre[0] - r, centre[1] - r, centre[2] - r], - max: [centre[0] + r, centre[1] + r, centre[2] + r], - } - } - Curve2Spec::Spline { control_points, .. } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in control_points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { - min: *origin, - max: *origin, - }, - } -} - -impl DocApiBackend for MockBackend { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - Ok(self.body(id)?.clone()) - } - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let id = self.alloc("Solid3D"); - self.bodies.insert(id, body.clone()); - Ok(id) - } - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - if self.bodies.insert(id, body.clone()).is_none() { - return Err(ApiError::UnknownId(id)); - } - Ok(()) - } - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - if spec.block_name.is_empty() { - return Err(ApiError::validation("CreateInsert", "empty block name")); - } - Ok(self.alloc("Insert")) - } - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - let id = self.alloc("Viewport"); - self.viewport_views - .insert(id, (spec.view_target, spec.view_height)); - Ok(id) - } - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - let id = self.alloc("Text"); - self.text_values.insert(id, spec.value.clone()); - Ok(id) - } - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - let id = self.alloc("MText"); - self.text_values.insert(id, spec.value.clone()); - Ok(id) - } - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { - e.insert(value.to_string()); - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn text_content(&self, id: ObjectId) -> ApiResult { - self.text_values - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - Ok(id) - } - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - self.hatch_boundaries - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) - + (spec.first_point[1] - spec.second_point[1]).powi(2) - + (spec.first_point[2] - spec.second_point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - Ok(id) - } - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.center[0] - spec.point[0]).powi(2) - + (spec.center[1] - spec.point[1]).powi(2) - + (spec.center[2] - spec.point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - self.add_dimension_radius(spec) - } - fn add_dimension_angular( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); - Ok(id) - } - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - self.dimension_measurements - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - if spec.tag.is_empty() { - return Err(ApiError::validation( - "CreateAttributeDefinition", - "empty tag", - )); - } - Ok(self.alloc("AttributeDefinition")) - } - fn add_dimension_angular2ln( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); - Ok(id) - } - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - Ok(self.alloc("Table")) - } - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let attrs = self.attributes_store.entry(id).or_default(); - if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { - a.1 = value.to_string(); - } else { - attrs.push((tag.to_string(), value.to_string())); - } - Ok(()) - } - fn attributes(&self, id: ObjectId) -> ApiResult> { - self.attributes_store - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn block_entities(&self, _block_name: &str) -> ApiResult> { - Ok(Vec::new()) - } - fn add_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - Ok(self.alloc("RasterImage")) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - if sections.len() < 2 { - return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); - } - let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; - self.store_solid(&body) - } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - self.viewport_views.insert(id, (view_target, view_height)); - Ok(()) - } - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - self.viewport_views - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - let bounds = self.bodies.get(&id).and_then(|b| { - cadkernel::brep::body_bounds(b).map(|bb| Aabb { - min: bb.min, - max: bb.max, - }) - }); - Ok(EntityView { - id, - kind, - layer, - bounds, - }) - } - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if let Some(body) = self.bodies.get(&id).cloned() { - let place = cadkernel::brep::Placement { - x_axis: placement.x_axis, - y_axis: placement.y_axis, - z_axis: placement.z_axis, - origin: placement.origin, - }; - let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.bodies.insert(id, moved); - } - Ok(()) - } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - Err(ApiError::Unsupported("profiles not mocked".into())) - } - fn bounds(&mut self, id: ObjectId) -> ApiResult { - // Solids via the kernel body; 2D curves from their stored spec. - if let Some(body) = self.bodies.get(&id) { - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - return Ok(Aabb { - min: bb.min, - max: bb.max, - }); - } - if let Some(spec) = self.curves.get(&id) { - return Ok(curve_bounds(spec)); - } - Err(ApiError::UnknownId(id)) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) - } - fn volume(&mut self, id: ObjectId) -> ApiResult { - let body = self.body(id)?; - Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) - } - fn entity_exists(&self, id: ObjectId) -> bool { - self.kinds.contains_key(&id) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.contains_key(&id) - } - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.revision) - } - fn push_undo(&mut self, _label: &str) {} - fn finalize_op(&mut self) { - self.undo_steps += 1; - self.revision += 1; - } -} - -/// Signed volume + centroid of a closed mesh — delegates to the crate's single -/// source of truth (divergence theorem) so the example mock can't drift. -fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - ocs_doc_api::geom::mesh_volume_centroid(mesh) -} diff --git a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs deleted file mode 100644 index 3d3312f11..000000000 --- a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Example 1 — Create solid primitives and query their geometry. -//! -//! Run: `cargo run --example e1_primitives_and_queries --features host` -//! -//! Shows: `doc.solids().create_*` for the supported primitives, and the read -//! queries `bounds()` / `volume()` / `centroid()` on a `Solid`. - -mod common; - -use ocs_doc_api::DocApi; - -fn main() -> ocs_doc_api::ApiResult<()> { - // In-process document over the mock backend (no host binary needed). - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // The supported solid primitives. - let block = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])?; - let ball = doc.solids().create_sphere([5.0, 5.0, 5.0], 6.0)?; - let cyl = doc.solids().create_cylinder([0.0, 0.0, 0.0], 4.0, 12.0)?; - - // Geometric queries are entity methods — one host call each. - let bb = block.bounds()?; - let vol = ball.volume()?; - let cg = cyl.centroid()?; - - println!("cuboid bounds : {bb:?}"); - println!("sphere volume : {vol:.2} (analytic ~904.78)"); - println!("cylinder centroid: {cg:?}"); - - // Read-only batching: same queries, ONE round-trip. - let res = doc.query_batch(|q| { - q.bounds(&block); - q.volume(&ball); - q.centroid(&cyl); - })?; - let (bb2, vol2, cg2) = (res.bounds(0)?, res.volume(1)?, res.centroid(2)?); - assert_eq!(bb, bb2); - assert!((vol - vol2).abs() < 1e-9); - assert_eq!(cg, cg2); - println!("query_batch matches the per-call results"); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs deleted file mode 100644 index dd117c6fd..000000000 --- a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Example 2 — Construct two solids and intersect them (the canonical boolean). -//! -//! Run: `cargo run --example e2_intersect_two_solids --features host` -//! -//! Shows: `intersect` / `union` / `subtract` on `Solid`, `erase_sources` -//! semantics (the result lives at the first input's id, the second is erased), -//! and `OpGroup` cleanup on failure. - -mod common; - -use ocs_doc_api::{DocApi, HasId, OpGroup}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - let mut grp = OpGroup::new(); - - // Two overlapping boxes: [0,10]^3 and [5,15]^3. - let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]))?; - let b = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]))?; - - // intersect -> SolidBoolean{Intersection, erase_sources:true}. The result is - // returned AT `a`'s id; `b` is erased. [5,10]^3 = volume 125. - let lens = match a.intersect(&b) { - Ok(l) => { - grp.commit(); // keep everything - l - } - Err(e) => { - grp.compensate(&doc)?; // best-effort: delete the two boxes - return Err(e); - } - }; - - assert_eq!(lens.id(), a.id(), "result lives at the first input's id"); - let vol = lens.volume()?; - let bb = lens.bounds()?; - println!("intersection volume = {vol:.2} (expected 125)"); - println!("intersection bounds = {bb:?}"); - - // union / subtract follow the same shape. Fresh inputs (b was erased above). - let c = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let d = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - let uni = c.union(&d)?; - // union volume = 1000 + 1000 - 125 (overlap) = 1875. - println!("union volume = {:.2} (expected 1875)", uni.volume()?); - - let e = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let f = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - let diff = e.subtract(&f)?; - println!("difference volume = {:.2} (expected 875)", diff.volume()?); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs deleted file mode 100644 index 1229f2997..000000000 --- a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Example 3 — Transform a solid and chain geometric operations. -//! -//! Run: `cargo run --example e3_transform_and_chain --features host` -//! -//! Shows: `transform(PlacementSpec)` (in-place, same ObjectId), how bounds shift -//! exactly by the translation, and chaining intersect -> transform. - -mod common; - -use ocs_doc_api::{DocApi, PlacementSpec}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - - // Boolean first: lens = [5,10]^3. - let lens = a.intersect(&b)?; - let before = lens.bounds()?; - println!("lens bounds before transform: {before:?}"); - - // Transform is in-place: the SAME ObjectId is preserved; bounds shift by the - // translation. [5,10]^3 moved +100 in X -> [105,110] x [5,10] x [5,10]. - lens.transform(PlacementSpec::at([100.0, 0.0, 0.0]))?; - let after = lens.bounds()?; - println!("lens bounds after +100 X : {after:?}"); - assert!((after.min[0] - 105.0).abs() < 1e-4); - assert!((after.max[0] - 110.0).abs() < 1e-4); - assert_eq!(after.min[1], before.min[1], "Y unchanged"); - - // Volume is invariant under a rigid transform. - let vol = lens.volume()?; - println!("lens volume after transform = {vol:.2} (still ~125)"); - assert!((vol - 125.0).abs() < 1.0); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs deleted file mode 100644 index eb1ff0b5b..000000000 --- a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Example 4 — 2D entities and bulk creation. -//! -//! Run: `cargo run --example e4_2d_entities_and_bulk --features host` -//! -//! Shows: the minimal-2D constructors (`create_line`/`create_circle`/ -//! `create_polyline`/`create_point`), `bounds()` on 2D entities, and the bulk op -//! `create_points` (one op, all-or-nothing, one undo step). - -mod common; - -use ocs_doc_api::{DocApi, HasId}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // Individual 2D constructors. - let line = doc - .curves() - .create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - let circle = doc.curves().create_circle([5.0, 5.0, 0.0], 3.0)?; - let poly = doc.curves().create_polyline( - &[ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1.0, 0.0], - [0.0, 1.0, 0.0], - ], - true, - )?; - - println!("line bounds: {:?}", line.bounds()?); // [0,10] in X - println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius - println!("poly bounds: {:?}", poly.bounds()?); // unit square - - // Bulk: 1000 points in ONE op (all-or-nothing, one undo step — not 1000). - let coords: Vec<[f64; 3]> = (0..1000) - .map(|i| [i as f64, (i % 10) as f64, 0.0]) - .collect(); - let pts = doc.curves().create_points(&coords)?; - println!("created {} points in one bulk op", pts.len()); - assert_eq!(pts.len(), 1000); - - // Generic lookup: any id -> Entity -> kind. - let e = doc.entities().get(line.id())?; - println!("line entity kind: {}", e.view()?.kind); - - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs deleted file mode 100644 index 2cc82dbe5..000000000 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Example 5 — Failure semantics: atomicity, OpGroup compensation, revision guard. -//! -//! Run: `cargo run --example e5_failure_and_guards --features host` -//! -//! Shows: per-op atomicity (a failed op applies nothing and moves no revision), -//! `OpGroup` best-effort cleanup, `ApiError` handling, and the `assert_revision` -//! read-guard. - -mod common; - -use ocs_doc_api::{ApiError, DocApi, HasId, ObjectId, OpGroup}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // ── Per-op atomicity: a boolean against a deleted id fails cleanly ── - let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let ghost = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)?; - ghost.delete()?; // now a stale handle - - let rev_before = doc.revision()?; - match a.intersect(&ghost) { - Err(ApiError::UnknownId(id)) => println!("boolean failed as expected: unknown id {id:?}"), - Err(e) => println!("boolean failed: {e}"), - Ok(_) => panic!("expected the boolean to fail"), - } - // `a` is still live (failed op mutated nothing) and the revision did not move. - assert!(a.bounds().is_ok(), "input solid survives a failed boolean"); - assert_eq!( - doc.revision()?, - rev_before, - "failed op records no revision bump" - ); - - // ── OpGroup: clean up a partially-built logical operation on failure ── - let mut grp = OpGroup::new(); - let p1 = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [4.0; 3]))?; - let p2 = grp.track(doc.solids().create_sphere([2.0, 2.0, 2.0], 3.0))?; - // Simulate a downstream failure: abandon the group and delete what we made. - grp.compensate(&doc)?; - for h in [p1.id(), p2.id()] { - assert!(doc.entities().get(h).is_err(), "compensated entity is gone"); - } - println!("OpGroup compensated: created entities deleted"); - - // ── assert_revision: read-modify-write guard ── - let rev = doc.revision()?; - doc.assert_revision(rev)?; // unchanged -> ok - doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3])?; // moves the revision - match doc.assert_revision(rev) { - Err(ApiError::Validation { reason, .. }) => println!("revision guard caught: {reason}"), - other => panic!("expected the revision guard to fail, got {other:?}"), - } - - // A totally unknown id also surfaces a structured error. - assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err()); - println!("failure semantics verified"); - Ok(()) -} diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml deleted file mode 100644 index 4de4cf6eb..000000000 --- a/crates/ocs_doc_api/spec/entities.toml +++ /dev/null @@ -1,566 +0,0 @@ -# Curated source of truth for ocs_doc_api codegen (plan §10, decision #6). -# Maps entity families + their construction ops, geometric actions, and queries -# to kernel fns / acadrust variants. build.rs regenerates src/gen/* from this; -# CI fails on drift. New op/query variants append at the END only (bincode -# discriminant stability). -# -# `status` values: "supported" (default), "unsupported" (present but errors), -# "planned" (roadmap; no callable surface). Generic handle methods -# (bounds/transform/delete) are documented once in build.rs and apply to every -# family unless overridden here. - -[meta] -envelope_version = 1 - -# ============================================================================ -# SOLID FAMILY (phase 1) — B-rep solids via cadkernel -# ============================================================================ -[[family]] -name = "Solid" -acadrust_variant = "Solid3D" -handle = "Solid" -collection = "solids" -description = "B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT)." - -[[family.constructor]] -name = "create_cuboid" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] -op = "CreateSolid" -fixed = { primitive = "Cuboid" } - -[[family.constructor]] -name = "create_sphere" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Sphere" } - -[[family.constructor]] -name = "create_cylinder" -args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Cylinder" } - -[[family.constructor]] -name = "create_cone" -args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Cone" } - -[[family.constructor]] -name = "create_torus" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_radius", ty = "f64" }, { name = "minor_radius", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Torus" } - -[[family.constructor]] -name = "create_wedge" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] -op = "CreateSolid" -fixed = { primitive = "Wedge" } - -[[family.constructor]] -name = "extrude" -args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }] -op = "Extrude" - -[[family.constructor]] -name = "revolve" -args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] -op = "Revolve" - -[[family.constructor]] -name = "loft" -args = [{ name = "profiles", ty = "&[EntityRef]" }] -op = "Loft" - -[[family.method]] -name = "intersect" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Intersection", erase_sources = true } - -[[family.method]] -name = "union" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Union", erase_sources = true } - -[[family.method]] -name = "subtract" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Difference", erase_sources = true } - -[[family.method]] -name = "volume" -kind = "query" -query = "GetVolume" -returns = "f64" - -[[family.method]] -name = "centroid" -kind = "query" -query = "GetCentroid" -returns = "[f64; 3]" - -[[family.method]] -name = "intersects" -kind = "query" -query = "GetIntersects" -args = [{ name = "other", ty = "Solid" }] -returns = "bool" - -# ============================================================================ -# CURVE FAMILY (phase 1, minimal 2D) -# ============================================================================ -[[family]] -name = "Line" -acadrust_variant = "Line" -handle = "Line" -collection = "curves" - -[[family.constructor]] -name = "create_line" -args = [{ name = "start", ty = "[f64; 3]" }, { name = "end", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Line" } - -[[family]] -name = "Circle" -acadrust_variant = "Circle" -handle = "Circle" -collection = "curves" - -[[family.constructor]] -name = "create_circle" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Circle" } - -[[family]] -name = "Polyline" -acadrust_variant = "LwPolyline" -handle = "Polyline" -collection = "curves" - -[[family.constructor]] -name = "create_polyline" -args = [{ name = "points", ty = "&[[f64; 3]]" }, { name = "closed", ty = "bool" }] -op = "CreateCurve" -fixed = { curve = "Polyline" } - -[[family.method]] -name = "add_vertex" -kind = "op" -op = "AddVertex" -args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }] -returns = "()" - -[[family]] -name = "Point" -acadrust_variant = "Point" -handle = "Point" -collection = "curves" - -[[family.constructor]] -name = "create_point" -args = [{ name = "position", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Point" } - -[[family.constructor]] -name = "create_points" -args = [{ name = "positions", ty = "&[[f64; 3]]" }] -op = "CreateMany" -fixed = { curve = "Point" } -bulk = true -returns = "Vec" - -[[family]] -name = "Arc" -acadrust_variant = "Arc" -handle = "ArcCurve" -collection = "curves" - -[[family.constructor]] -name = "create_arc" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "start_angle", ty = "f64" }, { name = "end_angle", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Arc" } - -[[family]] -name = "Ellipse" -acadrust_variant = "Ellipse" -handle = "Ellipse" -collection = "curves" - -[[family.constructor]] -name = "create_ellipse" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_axis", ty = "[f64; 3]" }, { name = "ratio", ty = "f64" }, { name = "start", ty = "f64" }, { name = "end", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Ellipse" } - -[[family]] -name = "Spline" -acadrust_variant = "Spline" -handle = "Spline" -collection = "curves" - -[[family.constructor]] -name = "create_spline" -args = [{ name = "degree", ty = "i32" }, { name = "control_points", ty = "&[[f64; 3]]" }, { name = "knots", ty = "&[f64]" }, { name = "weights", ty = "&[f64]" }] -op = "CreateCurve" -fixed = { curve = "Spline" } - -[[family]] -name = "Ray" -acadrust_variant = "Ray" -handle = "Ray" -collection = "curves" - -[[family.constructor]] -name = "create_ray" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Ray" } - -[[family]] -name = "XLine" -acadrust_variant = "XLine" -handle = "XLine" -collection = "curves" - -[[family.constructor]] -name = "create_xline" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "XLine" } - -# ============================================================================ -# ANNOTATION FAMILY (phase 2b) — text annotations -# ============================================================================ -[[family]] -name = "Text" -acadrust_variant = "Text" -handle = "Text" -collection = "curves" -description = "Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." - -[[family.constructor]] -name = "create_text" -args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateText" - -[[family.method]] -name = "content" -kind = "query" -query = "GetTextContent" -returns = "String" - -[[family.method]] -name = "set_content" -kind = "op" -op = "SetTextContent" -args = [{ name = "value", ty = "&str" }] -returns = "()" - -[[family]] -name = "MText" -acadrust_variant = "MText" -handle = "MText" -collection = "curves" -description = "Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." - -[[family.constructor]] -name = "create_mtext" -args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }] -op = "CreateMText" - -[[family.method]] -name = "content" -kind = "query" -query = "GetTextContent" -returns = "String" - -[[family.method]] -name = "set_content" -kind = "op" -op = "SetTextContent" -args = [{ name = "value", ty = "&str" }] -returns = "()" - -# ============================================================================ -# HATCH FAMILY (phase 2b) — filled regions -# ============================================================================ -[[family]] -name = "Hatch" -acadrust_variant = "Hatch" -handle = "Entity" -collection = "curves" -description = "Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete." - -[[family.constructor]] -name = "create_hatch" -args = [{ name = "boundary", ty = "&[[f64; 2]]" }, { name = "solid", ty = "bool" }] -op = "CreateHatch" - -[[family.method]] -name = "boundary" -kind = "query" -query = "GetHatchBoundary" -returns = "Vec>" - -# ============================================================================ -# DIMENSION FAMILY (phase 2c) — linear dimensions -# ============================================================================ -[[family]] -name = "Dimension" -acadrust_variant = "Dimension" -handle = "Dimension" -collection = "curves" -description = "Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points." - -[[family.constructor]] -name = "create_dimension_linear" -args = [{ name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "definition_point", ty = "[f64; 3]" }] -op = "CreateDimensionLinear" - -[[family.constructor]] -name = "create_dimension_radius" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] -op = "CreateDimensionRadius" - -[[family.constructor]] -name = "create_dimension_diameter" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] -op = "CreateDimensionDiameter" - -[[family.constructor]] -name = "create_dimension_angular" -args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] -op = "CreateDimensionAngular" - -[[family.constructor]] -name = "create_dimension_angular2ln" -args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] -op = "CreateDimensionAngular2Ln" - -[[family.method]] -name = "measurement" -kind = "query" -query = "GetDimensionMeasurement" -returns = "f64" - -# ============================================================================ -# CONTAINER FAMILY (phase 3) — block references -# ============================================================================ -[[family]] -name = "Insert" -acadrust_variant = "Insert" -handle = "Entity" -collection = "entities" -description = "Block reference. Place an existing BlockRecord by name; transform moves its insert point." - -[[family.constructor]] -name = "create_insert" -args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateInsert" - -[[family.method]] -name = "attributes" -kind = "query" -query = "GetAttributes" -returns = "Vec<(String, String)>" - -[[family.method]] -name = "set_attribute" -kind = "op" -op = "SetAttribute" -args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }] -returns = "()" - -# ============================================================================ -# ATTRIBUTE-DEFINITION FAMILY (phase 3-ii) — in-block attribute definitions -# ============================================================================ -[[family]] -name = "AttributeDefinition" -acadrust_variant = "AttributeDefinition" -handle = "Entity" -collection = "entities" -description = "In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point." - -[[family.constructor]] -name = "create_attribute_definition" -args = [{ name = "tag", ty = "&str" }, { name = "prompt", ty = "&str" }, { name = "default_value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateAttributeDefinition" - -# ============================================================================ -# PAPER-SPACE FAMILY (phase 4) — viewports -# ============================================================================ -[[family]] -name = "Viewport" -acadrust_variant = "Viewport" -handle = "Entity" -collection = "entities" -description = "Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center." - -[[family.constructor]] -name = "create_viewport" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "width", ty = "f64" }, { name = "height", ty = "f64" }, { name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] -op = "CreateViewport" - -[[family.method]] -name = "viewport_view" -kind = "query" -query = "GetViewportView" -returns = "([f64; 3], f64)" - -[[family.method]] -name = "set_view" -kind = "op" -op = "SetViewportView" -args = [{ name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] -returns = "()" - -# ============================================================================ -# MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create. -# These families report a real `kind` via GetEntity and coarse bounds where -# computable; they have no typed construction/geometric ops in v1. -# ============================================================================ -[[family]] -name = "RasterImage" -acadrust_variant = "RasterImage" -handle = "Entity" -collection = "entities" -description = "Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete." - -[[family.constructor]] -name = "create_raster_image" -args = [{ name = "file_path", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "u_vector", ty = "[f64; 3]" }, { name = "v_vector", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 2]" }] -op = "CreateRasterImage" - -[[family]] -name = "Table" -acadrust_variant = "Table" -handle = "Entity" -collection = "entities" -description = "Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete." - -[[family.constructor]] -name = "create_table" -args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] -op = "CreateTable" - -# ============================================================================ -# XDATA / XRECORD FAMILY — application-specific extended data roundtrip -# ============================================================================ -[[family]] -name = "XRecord" -acadrust_variant = "XRecord" -handle = "XRecord" -collection = "entities" -description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload." - -[[family.constructor]] -name = "create_xrecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -op = "CreateXRecord" -returns = "XRecord" - -[[family.method]] -name = "payload" -kind = "query" -query = "GetXRecord" -returns = "XRecordSpec" - -[[family.method]] -name = "set_payload" -kind = "op" -op = "SetXRecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -returns = "()" - -# ============================================================================ -# Cross-cutting ops (generic handles + bulk) — documented in build.rs -# ============================================================================ -# - Generic handle methods on EVERY handle: bounds() -> Aabb, transform(placement), -# delete(), id() -> ObjectId. (transform is solids-only in v1.) -# - EntityCollection: get(id) -> Entity, delete(id), transform_many(&ids, pl), -# delete_many(&ids). -# - Bulk ops (CreateMany / TransformMany / DeleteMany): single op, all-or-nothing -# via upfront validation, item-capped (BULK_ITEM_CAP). -# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. -# - OpGroup: best-effort client-side failure cleanup (NOT a transaction). - -# ============================================================================ -# LAYER family (named drawing layers) -# ============================================================================ -[[family]] -name = "Layer" -handle = "Layer" -collection = "layers" -description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()." - -[[family.method]] -name = "list" -kind = "query" -query = "ListLayers" -returns = "Vec" - -[[family.method]] -name = "create" -kind = "op" -op = "CreateLayer" -args = [{ name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "update" -kind = "op" -op = "UpdateLayer" -args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "delete" -kind = "op" -op = "DeleteLayer" -args = [{ name = "name", ty = "String" }] -returns = "()" - -# ---- Kernel/op mapping (reference for the executor + docs) ---- -[[kernel_map]] -op = "CreateSolid.Cuboid" -kernel = "cadkernel::brep::make::cuboid" -returns = "Option" - -[[kernel_map]] -op = "CreateSolid.Sphere" -kernel = "cadkernel::brep::make::sphere" -returns = "Option" - -[[kernel_map]] -op = "SolidBoolean" -kernel = "cadkernel::brep::combine" -returns = "Result" - -[[kernel_map]] -op = "Transform" -kernel = "cadkernel::brep::transform" -returns = "Option" - -[[kernel_map]] -op = "GetBounds" -kernel = "cadkernel::brep::body_bounds" -returns = "Option" - -[[kernel_map]] -op = "GetVolume" -kernel = "cadkernel::brep::mesh_body + divergence" -returns = "computed" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs deleted file mode 100644 index 51e4cfafe..000000000 --- a/crates/ocs_doc_api/src/backend.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! The thin host hook. The host implements this trait; -//! the crate's [`crate::executor`] drives it. Both the in-process transport and -//! the host's IPC executor call into it. Logic lives in the versioned crate; the -//! host only provides scene primitives. - -use crate::error::{ApiError, ApiResult}; -use crate::id::ObjectId; -use crate::ops::{Curve2Spec, LayerInfo}; -use crate::query::{Aabb, EntityView}; -use crate::revision::GeometryRevision; - -/// A kernel B-rep solid body, opaque to the wire. On `host` this is -/// `cadkernel::brep::Body`; kernel keys never cross the API (decision #7). -pub type KernelBody = cadkernel::brep::Body; - -/// The per-op host hook. Every method maps to an already-present scene primitive. -/// The executor calls `push_undo` BEFORE applying a write op and `finalize_op` -/// AFTER — recording exactly one undo step + one geometry bump per applied op. -pub trait DocApiBackend { - // ── identity / solid lookup ──────────────────────────────────────────── - /// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on - /// miss). `UnknownId` if `id` is not a live solid. NOTE: returns an OWNED body - /// (a clone of the cache entry); use `with_body` for read-only queries. - fn resolve_body(&mut self, id: ObjectId) -> ApiResult; - - /// Borrow the cached body read-only for a query, avoiding the `resolve_body` - /// deep clone. Default: falls back to `resolve_body` (clone). The host overrides - /// this to borrow the `solid_models` cache in place (O(1), no copy). - fn with_body( - &mut self, - id: ObjectId, - f: &mut dyn FnMut(&KernelBody) -> ApiResult, - ) -> ApiResult { - let body = self.resolve_body(id)?; - f(&body) - } - - /// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`. - /// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` + - /// `set_sat_document` + `register_solid_model`). - fn store_solid(&mut self, body: &KernelBody) -> ApiResult; - - /// Replace the body of an existing solid in place (same `ObjectId`). - /// Re-embeds SAT and refreshes the solid cache. - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()>; - - // ── plain (2D) entities ──────────────────────────────────────────────── - /// Add a 2D curve entity; returns the fresh `ObjectId`. - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult; - - /// Prepare every entity before recording one undo step and committing the batch. - fn create_many(&mut self, specs: &[crate::ops::EntitySpec]) -> ApiResult>; - - /// Prepare all transformed geometry before committing any entity. - fn transform_many( - &mut self, - ids: &[ObjectId], - placement: &crate::PlacementSpec, - ) -> ApiResult<()>; - - /// Add a block reference (`INSERT`). Validates `block_name` exists; returns - /// the fresh `ObjectId`. `Validation` if the block is unknown. - fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; - - /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. - fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; - - /// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`. - fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult; - - /// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`. - fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult; - - /// Set the text content of a Text/MText annotation in place. - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()>; - - /// The text content of a Text/MText annotation. `Unsupported` for other kinds. - fn text_content(&self, id: ObjectId) -> ApiResult; - - /// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`. - fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult; - - /// A hatch's boundary loops (outer + islands) as closed polylines. - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>>; - - /// Add a linear `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult; - - /// Add a radial `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_radius( - &mut self, - spec: &crate::ops::DimensionRadialSpec, - ) -> ApiResult; - - /// Add a diameter `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_diameter( - &mut self, - spec: &crate::ops::DimensionRadialSpec, - ) -> ApiResult; - - /// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_angular( - &mut self, - spec: &crate::ops::DimensionAngularSpec, - ) -> ApiResult; - - /// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`. - fn add_attribute_definition( - &mut self, - spec: &crate::ops::AttributeDefinitionSpec, - ) -> ApiResult; - - /// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`. - fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult; - - /// Add a 2-line angular `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_angular2ln( - &mut self, - spec: &crate::ops::DimensionAngularSpec, - ) -> ApiResult; - - /// A dimension's measured value (distance for linear/radius, degrees for angular). - fn dimension_measurement(&self, id: ObjectId) -> ApiResult; - - /// Set an attribute `value` for `tag` on an insert (adds if absent). Insert-only. - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()>; - - /// An insert's attributes as (tag, value) pairs. Insert-only. - fn attributes(&self, id: ObjectId) -> ApiResult>; - - /// The entities inside a block definition (read-only traversal), as views. - fn block_entities(&self, block_name: &str) -> ApiResult>; - - /// Set a viewport's view target + zoom height in place. Viewport-only. - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()>; - - /// A viewport's view (target WCS + zoom height). Viewport-only. - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>; - - /// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition). - fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult; - - /// Loft a solid through >= 2 profile curve sets (each already resolved to - /// `geom2d::Curve`s on the XY plane). Returns the fresh `ObjectId`. - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult; - - /// Attach or replace an XDATA record for `application_name` on `id`. - /// `None` removes any existing record for that application. - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&crate::ops::XDataRecord>, - ) -> ApiResult<()>; - - /// Read the XDATA record for `application_name` on `id` (`None` if absent). - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult>; - - /// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`. - fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult; - - /// Replace an existing `XRECORD` object's payload in place. - fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>; - - /// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord). - fn xrecord(&self, id: ObjectId) -> ApiResult>; - - // ── layer CRUD ───────────────────────────────────────────────────────── - /// Create a new layer. Fails with `Validation` if the name already exists or is reserved. - fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Update an existing layer's properties by name. Fails with `Validation` if absent. - fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Delete a layer by name. Fails with `Validation` if the layer is "0", - /// the current layer, does not exist, or has entities. - fn delete_layer(&mut self, name: &str) -> ApiResult<()>; - - /// Look up a layer by raw name, normalizing it. Default implementation scans - /// `layers()`; backends with O(1) lookup should override. - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = crate::layer::normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers()? - .into_iter() - .find(|l| l.normalized_name() == norm) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - /// Move an entity to a different layer. The target layer must exist. - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>; - - /// List all layers in the document. - fn layers(&self) -> ApiResult>; - - /// The layer name of an entity. - fn entity_layer(&self, id: ObjectId) -> ApiResult; - - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` controls whether `EntityView::bounds` is populated; skip it - /// for lightweight list views. - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult>; - - /// The WCS location of a Point entity. - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>; - - /// The start and end WCS points of a Line entity. - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>; - - /// Can `id` be modified in place right now (exists, is the expected family, - /// not on a locked layer)? Read-only pre-check used before mutations. - /// Default: existence + not-locked (backends narrow the family check). - fn can_modify(&self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Remove any first-level entity by id. Returns `false` if absent; returns - /// `Err` if the entity exists but cannot be removed (e.g. locked layer). - fn remove_entity(&mut self, id: ObjectId) -> ApiResult; - - /// Can `id` be removed right now (exists and is not blocked, e.g. by a locked - /// layer)? Read-only; used by the boolean-erase path to validate BEFORE any - /// mutation so a mid-op failure cannot leave partial state. Default: existence-only. - fn can_remove(&self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Whether an object (named-object / XRecord / dictionary) exists. Distinct - /// from `entity_exists`, which is for first-class entities only. - fn object_exists(&self, id: ObjectId) -> bool; - - /// A generic, untyped view of any entity (id + kind + coarse bounds). - fn get_entity(&mut self, id: ObjectId) -> ApiResult; - - /// The 2D curve geometry of a profile entity (polyline/closed curve) for - /// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile. - fn profile_curves(&self, id: ObjectId) -> ApiResult>; - - fn profile_plane(&self, _id: ObjectId) -> ApiResult { - Ok(cadkernel::space::Plane::XY) - } - - /// Apply a rigid similarity to any entity in place (same `ObjectId`). - fn transform_entity( - &mut self, - id: ObjectId, - placement: &crate::ops::PlacementSpec, - ) -> ApiResult<()>; - - /// Validate that `id` exists and can be transformed, WITHOUT mutating. - /// Used by `TransformMany` to pre-validate all ids before `push_undo` so the - /// apply loop cannot fail part-way (all-or-nothing, the bulk contract). Default: - /// existence-only (backends that restrict transform to a family override). - fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Insert a vertex into a polyline at index `at` (polyline family only). - fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()>; - - // ── queries (no undo, no bump; `&mut` because solid resolution may - // populate the kernel body cache on miss) ───────────────────────────── - fn bounds(&mut self, id: ObjectId) -> ApiResult; - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]>; - fn volume(&mut self, id: ObjectId) -> ApiResult; - fn entity_exists(&self, id: ObjectId) -> bool; - fn revision(&self) -> GeometryRevision; - - // ── per-op boundary ──────────────────────────────────────────────────── - /// Called BEFORE applying a write op (records the before-state undo snapshot). - fn push_undo(&mut self, label: &str); - /// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump - /// + one publish). NOT called on failure (a failed op records nothing). - fn finalize_op(&mut self); - /// Discard an undo capture after preparation or validation failed. - fn cancel_op(&mut self) {} -} diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs deleted file mode 100644 index db1f0a22f..000000000 --- a/crates/ocs_doc_api/src/convert.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Entity conversions: map the crate's plain-data DTOs to acadrust -//! `EntityType` and back. Host feature only (uses the real acadrust types). This -//! module is host-side-capable but host-independent — it operates purely on -//! acadrust entities and crate DTOs, so any host can reuse it. - -use acadrust::entities::{ - Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine, -}; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; - -use crate::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId}; - -/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for -/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). -pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { - use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; - let curves = match entity { - EntityType::Line(l) => vec![KCurve::Line(KLine { - start: [l.start.x, l.start.y], - end: [l.end.x, l.end.y], - })], - EntityType::Circle(c) => vec![KCurve::Circle(KCircle { - centre: [c.center.x, c.center.y], - radius: c.radius, - })], - EntityType::Arc(a) => vec![KCurve::Arc(KArc { - centre: [a.center.x, a.center.y], - radius: a.radius, - start_angle: a.start_angle, - end_angle: a.end_angle, - })], - EntityType::LwPolyline(pl) => { - if pl.vertices.len() < 2 { - return Err(ApiError::validation( - "profile", - "polyline profile needs at least two vertices", - )); - } - KCurve::Polyline(cadkernel::geom2d::Polyline { - vertices: pl - .vertices - .iter() - .map(|v| cadkernel::geom2d::PolylineVertex { - position: [v.location.x, v.location.y], - bulge: v.bulge, - }) - .collect(), - closed: pl.is_closed, - }) - .segments() - } - _ => { - return Err(ApiError::Unsupported( - "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), - )) - } - }; - Ok(curves) -} - -/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid -/// entity in place. Supports the common 2D families; `Solid3D` goes through the -/// kernel path instead. Returns the transformed entity (same handle) for the -/// caller to `update_entity`. -pub fn transform_entity_geometry( - entity: &EntityType, - p: &crate::PlacementSpec, -) -> ApiResult { - crate::geom::validate_placement(p)?; - let matrix = acadrust::types::Matrix4 { - m: [ - [p.x_axis[0], p.y_axis[0], p.z_axis[0], p.origin[0]], - [p.x_axis[1], p.y_axis[1], p.z_axis[1], p.origin[1]], - [p.x_axis[2], p.y_axis[2], p.z_axis[2], p.origin[2]], - [0.0, 0.0, 0.0, 1.0], - ], - }; - let mut moved = entity.clone(); - moved.apply_transform(&acadrust::types::Transform::from_matrix(matrix)); - Ok(moved) -} - -fn v3(p: [f64; 3]) -> Vector3 { - Vector3::new(p[0], p[1], p[2]) -} - -/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a -/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise. -pub fn bulge_arc_segment( - start: [f64; 2], - end: [f64; 2], - bulge: f64, -) -> ApiResult { - use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; - let arc = cadkernel::geom2d::BulgeArc::from_bulge(start, end, bulge) - .ok_or_else(|| ApiError::validation("profile", "invalid bulge segment"))?; - // The kernel's Arc is counter-clockwise; reverse clockwise endpoints. - let (start_angle, end_angle) = if arc.sweep < 0.0 { - (arc.end_angle, arc.start_angle) - } else { - (arc.start_angle, arc.end_angle) - }; - Ok(KCurve::Arc(KArc { - centre: arc.center, - radius: arc.radius, - start_angle, - end_angle, - })) -} - -/// Build an acadrust entity for a 2D curve construction spec. -pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { - crate::validation::curve(spec)?; - let entity = match spec { - Curve2Spec::Line { start, end, .. } => { - let mut e = Line::new(); - e.start = v3(*start); - e.end = v3(*end); - EntityType::Line(e) - } - Curve2Spec::Circle { centre, radius, .. } => { - let mut e = Circle::new(); - e.center = v3(*centre); - e.radius = *radius; - EntityType::Circle(e) - } - Curve2Spec::Polyline { points, closed, .. } => { - let mut e = LwPolyline::new(); - e.vertices = points - .iter() - .map(|p| LwVertex { - location: Vector2::new(p[0], p[1]), - bulge: 0.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 0, - }) - .collect(); - e.elevation = points.first().map_or(0.0, |p| p[2]); - if points.iter().any(|p| p[2] != e.elevation) { - return Err(ApiError::validation( - "CreateCurve", - "polyline points must share an elevation", - )); - } - e.is_closed = *closed; - EntityType::LwPolyline(e) - } - Curve2Spec::Point { position, .. } => { - let mut e = Point::new(); - e.location = v3(*position); - EntityType::Point(e) - } - Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => { - let mut e = Arc::new(); - e.center = v3(*centre); - e.radius = *radius; - e.start_angle = *start_angle; - e.end_angle = *end_angle; - EntityType::Arc(e) - } - Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - // Validate the minor/major ratio so the (major-axis) bounds never - // under-bound: ratio must be in (0, 1]. - if !(*ratio > 0.0 && *ratio <= 1.0) { - return Err(ApiError::validation( - "CreateCurve", - format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), - )); - } - let mut e = Ellipse::new(); - e.center = v3(*centre); - e.major_axis = v3(*major_axis); - e.minor_axis_ratio = *ratio; - e.start_parameter = *start; - e.end_parameter = *end; - EntityType::Ellipse(e) - } - Curve2Spec::Spline { - degree, - control_points, - knots, - weights, - .. - } => { - let mut e = Spline::new(); - e.degree = *degree; - e.control_points = control_points.iter().map(|p| v3(*p)).collect(); - e.knots = knots.clone(); - e.weights = weights.clone(); - EntityType::Spline(e) - } - Curve2Spec::Ray { origin, direction, .. } => { - EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) - } - Curve2Spec::XLine { origin, direction, .. } => { - EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) - } - }; - Ok(entity) -} - -/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts). -pub fn entity_kind_name(entity: &EntityType) -> &'static str { - match entity { - EntityType::Solid3D(_) => "Solid3D", - EntityType::Line(_) => "Line", - EntityType::Circle(_) => "Circle", - EntityType::LwPolyline(_) => "LwPolyline", - EntityType::Polyline(_) => "Polyline", - EntityType::Point(_) => "Point", - EntityType::Arc(_) => "Arc", - EntityType::Ellipse(_) => "Ellipse", - EntityType::Spline(_) => "Spline", - EntityType::Ray(_) => "Ray", - EntityType::XLine(_) => "XLine", - EntityType::Insert(_) => "Insert", - EntityType::Viewport(_) => "Viewport", - EntityType::Text(_) => "Text", - EntityType::MText(_) => "MText", - EntityType::AttributeDefinition(_) => "AttributeDefinition", - EntityType::AttributeEntity(_) => "AttributeEntity", - // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. - EntityType::RasterImage(_) => "RasterImage", - EntityType::Table(_) => "Table", - EntityType::Leader(_) => "Leader", - EntityType::MultiLeader(_) => "MultiLeader", - EntityType::MLine(_) => "MLine", - EntityType::Mesh(_) => "Mesh", - EntityType::Helix(_) => "Helix", - EntityType::Region(_) => "Region", - EntityType::Body(_) => "Body", - EntityType::Surface(_) => "Surface", - EntityType::Face3D(_) => "Face3D", - EntityType::Dimension(_) => "Dimension", - EntityType::Hatch(_) => "Hatch", - _ => "Other", - } -} - -/// Existing entity bounds; unbounded or empty geometry returns an error. -pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let entity = entity.ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) { - return Err(ApiError::Unsupported("unbounded entity".into())); - } - let bounds = entity.as_entity().bounding_box(); - let min = [bounds.min.x, bounds.min.y, bounds.min.z]; - let max = [bounds.max.x, bounds.max.y, bounds.max.z]; - if min.iter().chain(&max).any(|v| !v.is_finite()) { - return Err(ApiError::geometry( - GeometryErrorKind::Empty, - "entity has no finite bounds", - )); - } - Ok(Aabb { min, max }) -} diff --git a/crates/ocs_doc_api/src/envelope.rs b/crates/ocs_doc_api/src/envelope.rs deleted file mode 100644 index 0f847740f..000000000 --- a/crates/ocs_doc_api/src/envelope.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The wire envelope. One request = ONE write op OR a query batch -//! (read-only batching is safe: no mutation, no undo, no rollback). - -use serde::{Deserialize, Serialize}; - -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::query::QueryResult; -use crate::revision::GeometryRevision; -use crate::ENVELOPE_VERSION; - -/// The single request unit crossing the transport. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DocApiEnvelope { - /// Crate protocol version (`ENVELOPE_VERSION`). Bridges must check this. - pub version: u16, - pub body: EnvelopeBody, -} - -impl DocApiEnvelope { - pub fn validate_version(&self) -> crate::ApiResult<()> { - if self.version != ENVELOPE_VERSION { - return Err(crate::ApiError::Unsupported(format!( - "envelope version {}", - self.version - ))); - } - Ok(()) - } - - pub fn op(operation: Operation) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Op(operation), - } - } - pub fn queries(queries: Vec) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Queries(queries), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum EnvelopeBody { - /// Exactly ONE write op (write batching removed, plan §13). Atomic by itself. - Op(Operation), - /// One-or-more read-only queries answered together (safe: no mutation/undo). - Queries(Vec), -} - -/// The outcome of an applied write op. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum OpOutcome { - /// A new entity was created; carries its fresh `ObjectId`. - NewId(ObjectId), - /// An existing entity was updated in place (same `ObjectId` preserved). - Updated(ObjectId), - /// Entities were deleted; carries the deleted ids (one for `Delete`, N for `DeleteMany`). - Deleted(Vec), - /// Many entities were created (bulk `CreateMany`); carries all fresh ids in order. - NewIds(Vec), -} - -impl OpOutcome { - /// The single fresh id, for ops that create exactly one entity. - pub fn new_id(&self) -> Option { - match self { - OpOutcome::NewId(id) => Some(*id), - _ => None, - } - } - /// All fresh ids, for bulk creates. - pub fn new_ids(&self) -> &[ObjectId] { - match self { - OpOutcome::NewIds(ids) => ids, - OpOutcome::NewId(id) => std::slice::from_ref(id), - _ => &[], - } - } -} - -/// The per-request result (and the query-batch answer carrier). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Receipt { - /// Present for `EnvelopeBody::Op`; absent for pure queries. - pub outcome: Option, - /// One `QueryResult` per `Query` in a `Queries` batch (in order). - pub query_results: Vec, - /// The geometry revision after this request (== before, for pure queries). - pub new_revision: GeometryRevision, -} diff --git a/crates/ocs_doc_api/src/error.rs b/crates/ocs_doc_api/src/error.rs deleted file mode 100644 index 783ec1434..000000000 --- a/crates/ocs_doc_api/src/error.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Structured error model. Serializable so IPC returns the -//! same error the in-process executor produced. - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::id::ObjectId; - -/// Which kind of kernel failure occurred (a mapped `cadkernel` `Snag`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GeometryErrorKind { - /// The operation produced an empty / degenerate body. - Empty, - /// Boolean refused (coincident geometry, unsupported cut, …). - BooleanFailed, - /// Invalid input to a kernel constructor (e.g. non-positive radius). - InvalidInput, - /// ACIS lift/parse/lower failed. - Acis, - /// Any other kernel error (message carries detail). - Other, -} - -/// The single error type for the whole API. `commit`/ops return -/// `Result<_, ApiError>`; on a failed op nothing is applied and no undo step is -/// recorded (per-op atomicity). -#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ApiError { - /// Input failed validation before any mutation (names the op + reason). - #[error("validation failed on {op}: {reason}")] - Validation { op: String, reason: String }, - - /// The geometry kernel reported a failure (a mapped `Snag`). - #[error("geometry kernel: {kind:?}: {msg}")] - Geometry { - kind: GeometryErrorKind, - msg: String, - }, - - /// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed). - #[error("unknown ObjectId {0:?}")] - UnknownId(ObjectId), - - /// The requested capability is not supported (e.g. over-cap bulk op, unimplemented family). - #[error("unsupported capability: {0}")] - Unsupported(String), - - /// The transport (IPC / channel) failed — disconnected, oversized, timeout. - #[error("transport: {0}")] - Transport(String), -} - -pub type ApiResult = Result; - -impl ApiError { - pub fn validation(op: &'static str, reason: impl Into) -> Self { - ApiError::Validation { - op: op.to_string(), - reason: reason.into(), - } - } - pub fn geometry(kind: GeometryErrorKind, msg: impl Into) -> Self { - ApiError::Geometry { - kind, - msg: msg.into(), - } - } -} diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs deleted file mode 100644 index 423fc0658..000000000 --- a/crates/ocs_doc_api/src/executor.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! The op executor: crate-owned logic mapping each -//! `Operation`/`Query` to `DocApiBackend` calls. Both the in-process transport -//! and the host's IPC executor use this — one implementation, versioned in the -//! crate. Per-op atomicity: a write op validates its inputs and computes its -//! geometry BEFORE calling `push_undo`, mutates, then `finalize_op` once. - -use crate::backend::DocApiBackend; -use crate::envelope::{OpOutcome, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP}; -use crate::query::QueryResult; - -/// Apply ONE write op atomically. On success: one undo step and an advanced revision. -/// On failure: nothing applied, no undo, no bump (the op validates before mutating). -pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { - let result = apply_op_inner(b, op); - if result.is_err() { - b.cancel_op(); - } - result -} - -fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { - let name = op.op_name(); - crate::validation::operation(&op)?; - match &op { - Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => { - let unique: std::collections::HashSet<_> = ids.iter().collect(); - if ids.is_empty() || unique.len() != ids.len() { - return Err(ApiError::validation( - name, - "batch ids must be nonempty and unique", - )); - } - } - Operation::SetXDataMany(records) => { - if records.is_empty() { - return Err(ApiError::validation( - name, - "SetXDataMany records must be nonempty", - )); - } - if records.len() > BULK_ITEM_CAP { - return Err(over_cap(name, records.len())); - } - let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect(); - if unique.len() != records.len() { - return Err(ApiError::validation( - name, - "SetXDataMany records must have unique entity ids", - )); - } - } - _ => {} - } - if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op - { - crate::geom::validate_placement(placement)?; - } - let outcome = match &op { - Operation::CreateSolid(prim) => { - let body = crate::geom::make_solid(prim)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateCurve(spec) => { - b.push_undo(name); - let id = b.add_curve(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Extrude { profile, direction } => { - let curves = profile_curves(b, *profile, name)?; - let body = crate::geom::extrude(b.profile_plane(*profile)?, &curves, *direction)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Revolve { - profile, - axis, - angle, - } => { - let curves = profile_curves(b, *profile, name)?; - let (pivot, axis_dir) = *axis; - let body = - crate::geom::revolve(b.profile_plane(*profile)?, &curves, pivot, axis_dir, *angle)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Transform { id, placement } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.transform_entity(*id, placement)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SolidBoolean { - op: bop, - a, - b: bid, - erase_sources, - } => { - // Resolve + combine BEFORE push_undo: pure computation, no mutation. - let ba = b.resolve_body(*a).map_err(|_| ApiError::UnknownId(*a))?; - let bb = b - .resolve_body(*bid) - .map_err(|_| ApiError::UnknownId(*bid))?; - let result = crate::geom::boolean(&ba, &bb, *bop)?; - // Validate the erase BEFORE mutating: if `b` cannot be removed (e.g. - // locked layer), fail now rather than after `update_solid(a)` has - // already committed a partial mutation. - if *erase_sources { - if a == bid { - return Err(ApiError::validation(name, "boolean source ids must differ")); - } - b.can_modify(*a)?; - b.can_remove(*bid)?; - } - b.push_undo(name); - // `erase_sources`: the result lives at `a` (updated in place), `b` erased. - // Otherwise store a fresh solid and leave both inputs untouched. - let out_id = if *erase_sources { - b.update_solid(*a, &result)?; - b.remove_entity(*bid)?; - *a - } else { - b.store_solid(&result)? - }; - b.finalize_op(); - OpOutcome::NewId(out_id) - } - Operation::AddVertex { id, at, point } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.add_vertex(*id, *at, *point)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::Delete { id } => { - // can_remove validates existence AND removability (locked layer) before - // any mutation, so a locked entity errors cleanly instead of a no-op - // reported as Deleted. - b.can_remove(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.remove_entity(*id)?; - b.finalize_op(); - OpOutcome::Deleted(vec![*id]) - } - Operation::CreateMany(specs) => { - if specs.len() > BULK_ITEM_CAP { - return Err(over_cap(name, specs.len())); - } - if specs.is_empty() { - return Err(ApiError::validation(name, "batch must not be empty")); - } - for spec in specs { - if let EntitySpec::Curve(curve) = spec { - crate::validation::curve(curve)?; - } - } - let ids = b.create_many(specs)?; - OpOutcome::NewIds(ids) - } - Operation::TransformMany { ids, placement } => { - if ids.len() > BULK_ITEM_CAP { - return Err(over_cap(name, ids.len())); - } - // Pre-validate EVERYTHING fallible before push_undo (all-or-nothing, - // the bulk contract): every id must exist AND be transformable, so the apply - // loop below cannot fail part-way. `transform_entity` after this is - // infallible-by-construction for the validated ids. - for (i, id) in ids.iter().enumerate() { - b.ensure_transformable(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - other => match other { - ApiError::Unsupported(msg) => ApiError::Validation { - op: name.to_string(), - reason: format!("index {i}: {msg}"), - }, - o => o, - }, - })?; - } - b.transform_many(ids, placement)?; - OpOutcome::Updated(*ids.first().unwrap_or(&ObjectId::NULL)) - } - Operation::DeleteMany(ids) => { - if ids.len() > BULK_ITEM_CAP { - return Err(over_cap(name, ids.len())); - } - // Pre-validate existence AND removability (locked layer) for every id - // before push_undo, so the apply loop cannot fail part-way. - for (i, id) in ids.iter().enumerate() { - b.can_remove(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(msg) => ApiError::Validation { - op: name.to_string(), - reason: format!("index {i}: {msg}"), - }, - other => other, - })?; - } - b.push_undo(name); - for id in ids { - b.remove_entity(*id)?; - } - b.finalize_op(); - OpOutcome::Deleted(ids.clone()) - } - Operation::CreateInsert(spec) => { - // Validate the block name exists before committing (no rollback needed). - b.push_undo(name); - let id = b.add_insert(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateViewport(spec) => { - b.push_undo(name); - let id = b.add_viewport(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateText(spec) => { - b.push_undo(name); - let id = b.add_text(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateMText(spec) => { - b.push_undo(name); - let id = b.add_mtext(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetTextContent { id, value } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_text_content(*id, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateHatch(spec) => { - // Validate the boundary (>= 3 points) before committing. - if spec.boundary.len() < 3 { - return Err(ApiError::validation( - name, - "hatch boundary needs >= 3 points", - )); - } - b.push_undo(name); - let id = b.add_hatch(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionLinear(spec) => { - b.push_undo(name); - let id = b.add_dimension_linear(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetAttribute { id, tag, value } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_attribute(*id, tag, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetViewportView { - id, - view_target, - view_height, - } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_viewport_view(*id, *view_target, *view_height)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateRasterImage(spec) => { - b.push_undo(name); - let id = b.add_raster_image(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Loft { profiles } => { - // Bound the section count like the bulk ops (one envelope can carry many - // ids; an uncapped loft would be an unbounded kernel call on the UI thread). - if profiles.len() > BULK_ITEM_CAP { - return Err(over_cap(name, profiles.len())); - } - // Resolve every profile to its curve set BEFORE push_undo (all-or-nothing). - if profiles.len() < 2 { - return Err(ApiError::validation(name, "loft needs >= 2 profiles")); - } - let mut sections = Vec::with_capacity(profiles.len()); - for (i, p) in profiles.iter().enumerate() { - let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?; - sections.push((b.profile_plane(*p)?, curves)); - } - b.push_undo(name); - let id = b.loft(§ions)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionRadius(spec) => { - b.push_undo(name); - let id = b.add_dimension_radius(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionDiameter(spec) => { - b.push_undo(name); - let id = b.add_dimension_diameter(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionAngular(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateAttributeDefinition(spec) => { - b.push_undo(name); - let id = b.add_attribute_definition(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateTable(spec) => { - // Validate a non-empty, rectangular grid before committing. - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - name, - "table needs a non-empty rectangular grid", - )); - } - b.push_undo(name); - let id = b.add_table(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionAngular2Ln(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular2ln(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateLayer(info) => { - b.push_undo(name); - b.create_layer(info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::UpdateLayer { name, info } => { - b.push_undo(name); - b.update_layer(name, info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::DeleteLayer { name } => { - b.push_undo(name); - b.delete_layer(name)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::SetEntityLayer { id, layer } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_entity_layer(*id, layer)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXData { - id, - application_name, - record, - } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_xdata(*id, application_name, record.as_ref())?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXDataMany(records) => { - // A later locked entity must not leave earlier records changed. - for (i, (id, _, _)) in records.iter().enumerate() { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(reason) => { - ApiError::validation(name, format!("index {i}: {reason}")) - } - other => other, - })?; - } - b.push_undo(name); - for (id, application_name, record) in records { - b.set_xdata(*id, application_name, record.as_ref())?; - } - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::CreateXRecord(spec) => { - b.push_undo(name); - let id = b.add_xrecord(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetXRecord { id, spec } => { - require_object_exists(b, *id, name)?; - b.push_undo(name); - b.set_xrecord(*id, spec)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - }; - Ok(Receipt { - outcome: Some(outcome), - query_results: Vec::new(), - new_revision: b.revision(), - }) -} - -/// Apply a batch of read-only queries (no undo, no bump; `&mut` because solid -/// resolution may populate the kernel body cache on miss). Bounded like the bulk -/// ops: a batch over `BULK_ITEM_CAP` is rejected before any work (the 64 MiB frame -/// cap bounds message size, not count). -pub fn apply_queries(b: &mut B, queries: Vec) -> ApiResult { - if queries.len() > BULK_ITEM_CAP { - return Err(ApiError::validation( - "Queries", - format!( - "query batch over cap: {} > {}", - queries.len(), - BULK_ITEM_CAP - ), - )); - } - let mut results = Vec::with_capacity(queries.len()); - for q in &queries { - let qname = q.query_name(); - let r = match q { - Query::GetEntity { id } => { - QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?) - } - Query::GetBounds { id } => { - QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?) - } - Query::GetCentroid { id } => { - QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?) - } - Query::GetVolume { id } => { - QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?) - } - Query::GetIntersects { a, b: bid } => { - let ba = b.bounds(*a).map_err(|e| label(qname, e))?; - let bb = b.bounds(*bid).map_err(|e| label(qname, e))?; - QueryResult::Intersects(ba.overlaps(&bb)) - } - Query::GetGeometryRevision => QueryResult::Revision(b.revision()), - Query::GetTextContent { id } => { - QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?) - } - Query::GetHatchBoundary { id } => { - QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?) - } - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement( - b.dimension_measurement(*id).map_err(|e| label(qname, e))?, - ), - Query::GetAttributes { id } => { - QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?) - } - Query::GetBlockEntities { block_name } => QueryResult::BlockEntities( - b.block_entities(block_name).map_err(|e| label(qname, e))?, - ), - Query::GetViewportView { id } => { - let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; - QueryResult::ViewportView { target, height } - } - Query::GetXData { - id, - application_name, - } => QueryResult::XData( - b.xdata(*id, application_name) - .map_err(|e| label(qname, e))? - .clone(), - ), - Query::GetXRecord { id } => { - QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else( - || ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")), - )?) - } - Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?), - Query::GetEntityLayer { id } => { - QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?) - } - Query::EnumerateEntities { - kind, - layer, - include_bounds, - } => QueryResult::Entities( - b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds) - .map_err(|e| label(qname, e))?, - ), - Query::GetPointPosition { id } => QueryResult::PointPosition( - b.point_position(*id).map_err(|e| label(qname, e))?, - ), - Query::GetLineGeometry { id } => { - let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?; - QueryResult::LineGeometry((start, end)) - } - }; - results.push(r); - } - Ok(Receipt { - outcome: None, - query_results: results, - new_revision: b.revision(), - }) -} - -/// Attach the query name to an error for better diagnostics (uses `query_name`). -fn label(query: &'static str, e: ApiError) -> ApiError { - match e { - ApiError::Validation { reason, .. } => ApiError::validation(query, reason), - ApiError::UnknownId(id) => ApiError::validation(query, format!("unknown ObjectId {id:?}")), - other => other, - } -} - -fn profile_curves( - b: &B, - profile: ObjectId, - op: &'static str, -) -> ApiResult> { - if !b.entity_exists(profile) { - return Err(ApiError::validation( - op, - format!("unknown ObjectId {profile:?}"), - )); - } - b.profile_curves(profile) -} - -/// Validate that an entity exists before performing an operation. -fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) - } -} - -/// Validate that a named object exists before performing an operation. -fn require_object_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.object_exists(id) { - Ok(()) - } else { - Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) - } -} - -fn over_cap(op: &'static str, n: usize) -> ApiError { - ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) -} - -fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError { - ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}")) -} - -fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError { - match e { - ApiError::Validation { reason, .. } => { - ApiError::validation(op, format!("index {i}: {reason}")) - } - other => other, - } -} - -/// Boolean op kind → kernel `Operation` (host feature only). -#[cfg(feature = "host")] -pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation { - match op { - BoolOp::Union => cadkernel::brep::Operation::Union, - BoolOp::Intersection => cadkernel::brep::Operation::Intersection, - BoolOp::Difference => cadkernel::brep::Operation::Difference, - } -} diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs deleted file mode 100644 index ef6f2b13d..000000000 --- a/crates/ocs_doc_api/src/facade.rs +++ /dev/null @@ -1,1413 +0,0 @@ -//! The object-centric facade. -//! -//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] → -//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) -//! → typed handles ([`Solid`], [`Line`], [`Circle`], [`Polyline`], [`Point`], -//! [`Entity`]) carrying methods. Every handle = `{ ObjectId, Arc }` — -//! `Clone`, `Send`, `Sync`; it never holds kernel geometry. Per-op autocommit: -//! each method is ONE atomic host call = one undo step (no transactions). - -use std::sync::Arc; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec}; -use crate::query::{Aabb, EntityView, QueryResult}; -use crate::revision::GeometryRevision; -use crate::transport::Transport; - -/// Shared session: the transport. Held by every handle. The transport already -/// carries the tab binding (IPC) or the backend (in-process), so the session -/// stores no per-tab field. -#[derive(Clone)] -pub struct Session { - transport: Arc, - valid_tab: bool, -} - -impl Session { - fn validate_tab(&self) -> ApiResult<()> { - if !self.valid_tab { - return Err(ApiError::validation( - "document", - "transport is bound to another tab", - )); - } - Ok(()) - } - fn same_transport(&self, other: &Session) -> ApiResult<()> { - self.validate_tab()?; - other.validate_tab()?; - if !Arc::ptr_eq(&self.transport, &other.transport) { - return Err(ApiError::validation( - "document", - "handles belong to different sessions", - )); - } - Ok(()) - } - fn apply_op(&self, op: Operation) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::op(op)) - } - fn apply_queries(&self, queries: Vec) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::queries(queries)) - } - fn one_query(&self, q: Query) -> ApiResult { - let mut r = self.apply_queries(vec![q])?; - let result = r.query_results.drain(..).next(); - result.ok_or_else(|| ApiError::Transport("empty query result".into())) - } -} - -/// The API root: owns the session (transport + active tab). Entry point. -#[derive(Clone)] -pub struct DocApi { - transport: Arc, - active_tab: u64, -} - -impl DocApi { - /// Connect over an existing transport (IPC or in-process) bound to `active_tab`. - pub fn connect(transport: Arc, active_tab: u64) -> Self { - Self { - transport, - active_tab, - } - } - /// Connect in-process over a backend (host feature). - #[cfg(feature = "host")] - pub fn in_process( - backend: B, - active_tab: u64, - ) -> Self { - Self::connect( - Arc::new(crate::transport::InProcess::new(backend)), - active_tab, - ) - } - - /// The active tab id this root is bound to. - pub fn active_tab(&self) -> u64 { - self.active_tab - } - - /// Liveness probe of the host/transport. - pub fn alive(&self) -> bool { - self.transport.alive() - } - - /// Bind a document to the transport's tab. Requests for another tab fail. - pub fn document(&self, tab: u64) -> Document { - Document { - session: Session { - transport: Arc::clone(&self.transport), - valid_tab: tab == self.active_tab, - }, - } - } -} - -/// One open document tab: factory + lookup + queries + read-guards. -#[derive(Clone)] -pub struct Document { - session: Session, -} - -impl Document { - pub fn solids(&self) -> SolidCollection { - SolidCollection { - session: self.session.clone(), - } - } - pub fn curves(&self) -> CurveCollection { - CurveCollection { - session: self.session.clone(), - } - } - pub fn entities(&self) -> EntityCollection { - EntityCollection { - session: self.session.clone(), - } - } - - /// Current geometry revision (query, no bump). - pub fn revision(&self) -> ApiResult { - match self.session.one_query(Query::GetGeometryRevision)? { - QueryResult::Revision(r) => Ok(r), - _ => Err(ApiError::Transport("unexpected revision result".into())), - } - } - - /// Standalone read-guard: fail with `ApiError::Validation` if the document's - /// revision moved past `expected` (read-modify-write guard; NOT a batch pre-condition). - pub fn assert_revision(&self, expected: GeometryRevision) -> ApiResult<()> { - let now = self.revision()?; - if now == expected { - Ok(()) - } else { - Err(ApiError::Validation { - op: "assert_revision".to_string(), - reason: format!( - "revision moved: expected {expected:?}, now {now:?}; re-read and retry" - ), - }) - } - } - - /// Read-only traversal of a block definition's entities (ids + kinds + bounds). - pub fn block_entities(&self, block_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetBlockEntities { - block_name: block_name.to_string(), - })? { - QueryResult::BlockEntities(v) => Ok(v), - _ => Err(ApiError::Transport( - "unexpected block-entities result".into(), - )), - } - } - - /// List all layers in the document. - pub fn layers(&self) -> ApiResult> { - match self.session.one_query(Query::ListLayers)? { - QueryResult::Layers(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected layers result".into())), - } - } - - /// Create a new layer. One undo step; fails if the normalized name already - /// exists or is empty. - pub fn create_layer(&self, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "CreateLayer", - "empty layer name", - )); - } - self.session - .apply_op(Operation::CreateLayer(info.clone()))?; - Ok(()) - } - - /// Update an existing layer's properties by name. One undo step; `info.name` - /// becomes the new display name (case-insensitive key unchanged). - pub fn update_layer(&self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "UpdateLayer", - "empty layer name", - )); - } - self.session.apply_op(Operation::UpdateLayer { - name: name.to_string(), - info: info.clone(), - })?; - Ok(()) - } - - /// Delete a layer by name. One undo step; fails for layer "0", the current - /// layer, or any layer still referenced by entities. - pub fn delete_layer(&self, name: &str) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteLayer { - name: name.to_string(), - })?; - Ok(()) - } - - /// Set XDATA on many entities in a single atomic operation. - /// - /// Each tuple is `(entity_id, application_name, record)`. Pass `None` for - /// `record` to delete the application's data. Fails up-front if any id is - /// invalid; the whole batch is one undo step. - pub fn set_xdata_many(&self, items: I) -> ApiResult<()> - where - I: IntoIterator)>, - S: Into, - { - let updates: Vec<_> = items - .into_iter() - .map(|(id, app, record)| (id, app.into(), record)) - .collect(); - if updates.is_empty() { - return Ok(()); - } - self.session.apply_op(Operation::SetXDataMany(updates))?; - Ok(()) - } - - /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). - /// The closure records queries on a [`QueryBatch`]; the results are returned - /// in the same order as a [`QueryResults`] view the caller destructures. - pub fn query_batch(&self, f: F) -> ApiResult { - let mut qb = QueryBatch { - queries: Vec::new(), - session: self.session.clone(), - error: None, - }; - f(&mut qb); - if let Some(error) = qb.error { - return Err(error); - } - let receipt = self.session.apply_queries(qb.queries)?; - Ok(QueryResults { - results: receipt.query_results, - }) - } -} - -/// Records queries for [`Document::query_batch`]. Each method appends one query. -pub struct QueryBatch { - queries: Vec, - session: Session, - error: Option, -} - -impl QueryBatch { - fn check(&mut self, e: &impl HasId) { - if let Err(error) = e.validate_session(&self.session) { - self.error = Some(error); - } - } - pub fn bounds(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetBounds { id: e.id() }); - } - pub fn volume(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetVolume { id: e.id() }); - } - pub fn centroid(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetCentroid { id: e.id() }); - } - pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) { - self.check(a); - self.check(b); - self.queries.push(Query::GetIntersects { - a: a.id(), - b: b.id(), - }); - } - pub fn revision(&mut self) { - self.queries.push(Query::GetGeometryRevision); - } -} - -/// Ordered results of a [`Document::query_batch`], one per recorded query. -pub struct QueryResults { - results: Vec, -} - -impl QueryResults { - fn at(&self, i: usize) -> ApiResult<&QueryResult> { - self.results - .get(i) - .ok_or_else(|| ApiError::Transport("query result missing".into())) - } - pub fn bounds(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Bounds(b) => Ok(*b), - _ => Err(ApiError::Transport("result is not bounds".into())), - } - } - pub fn volume(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Volume(v) => Ok(*v), - _ => Err(ApiError::Transport("result is not volume".into())), - } - } - pub fn centroid(&self, i: usize) -> ApiResult<[f64; 3]> { - match self.at(i)? { - QueryResult::Centroid(c) => Ok(*c), - _ => Err(ApiError::Transport("result is not centroid".into())), - } - } - pub fn intersects(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Intersects(x) => Ok(*x), - _ => Err(ApiError::Transport("result is not intersects".into())), - } - } - pub fn revision(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Revision(r) => Ok(*r), - _ => Err(ApiError::Transport("result is not revision".into())), - } - } -} - -/// Something with an `ObjectId` (all handles + raw ids). -pub trait HasId { - fn id(&self) -> ObjectId; - /// Typed handles retain their session; raw ids are relative to the receiver. - fn session(&self) -> Option<&Session> { - None - } - fn validate_session(&self, target: &Session) -> ApiResult<()> { - if let Some(session) = self.session() { - session.same_transport(target)?; - } - Ok(()) - } -} -impl HasId for ObjectId { - fn id(&self) -> ObjectId { - *self - } -} - -// ── Typed handles ──────────────────────────────────────────────────────────── - -macro_rules! handle { - ($name:ident) => { - #[derive(Clone)] - pub struct $name { - session: Session, - id: ObjectId, - } - impl std::fmt::Debug for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, concat!(stringify!($name), "({:?})"), self.id) - } - } - impl $name { - fn new(session: Session, id: ObjectId) -> Self { - Self { session, id } - } - } - impl HasId for $name { - fn session(&self) -> Option<&Session> { - Some(&self.session) - } - fn id(&self) -> ObjectId { - self.id - } - } - }; -} - -macro_rules! entity_handle { - ($name:ident) => { - handle!($name); - impl $name { - pub fn bounds(&self) -> ApiResult { - match self.session.one_query(Query::GetBounds { id: self.id })? { - QueryResult::Bounds(b) => Ok(b), - _ => Err(ApiError::Transport("unexpected bounds result".into())), - } - } - pub fn delete(&self) -> ApiResult<()> { - self.session.apply_op(Operation::Delete { id: self.id })?; - Ok(()) - } - pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> { - self.session.apply_op(Operation::Transform { - id: self.id, - placement, - })?; - Ok(()) - } - /// Return the entity's current layer name (normalized uppercase). - pub fn layer(&self) -> ApiResult { - match self - .session - .one_query(Query::GetEntityLayer { id: self.id })? - { - QueryResult::EntityLayer(name) => Ok(name), - _ => Err(ApiError::Transport("unexpected entity layer result".into())), - } - } - /// Move the entity to a different layer. One undo step; fails if the - /// target layer does not exist or is locked. - pub fn set_layer(&self, layer: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetEntityLayer { - id: self.id, - layer: layer.to_string(), - })?; - Ok(()) - } - } - }; -} - -entity_handle!(Entity); -entity_handle!(Solid); -entity_handle!(Line); -entity_handle!(Circle); -entity_handle!(Polyline); -entity_handle!(Point); -entity_handle!(ArcCurve); -entity_handle!(Ellipse); -entity_handle!(Spline); -entity_handle!(Ray); -entity_handle!(XLine); -entity_handle!(Text); -entity_handle!(MText); -entity_handle!(Dimension); -handle!(XRecord); - -impl XRecord { - /// Read the XRECORD payload. - pub fn payload(&self) -> ApiResult { - match self.session.one_query(Query::GetXRecord { id: self.id })? { - QueryResult::XRecord(spec) => Ok(spec), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - /// Replace the XRECORD payload in place (one undo step). - pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> { - self.session.apply_op(Operation::SetXRecord { - id: self.id, - spec: spec.clone(), - })?; - Ok(()) - } -} - -impl Dimension { - /// The measured value of this dimension (distance for linear/radius, degrees - /// for angular). - pub fn measurement(&self) -> ApiResult { - match self - .session - .one_query(Query::GetDimensionMeasurement { id: self.id })? - { - QueryResult::DimensionMeasurement(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected measurement result".into())), - } - } -} - -impl Entity { - pub fn view(&self) -> ApiResult { - match self.session.one_query(Query::GetEntity { id: self.id })? { - QueryResult::Entity(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected entity result".into())), - } - } - /// This insert's attributes as (tag, value) pairs. Insert-only. - pub fn attributes(&self) -> ApiResult> { - match self - .session - .one_query(Query::GetAttributes { id: self.id })? - { - QueryResult::Attributes(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected attributes result".into())), - } - } - /// Set an attribute `value` for `tag` on this insert (adds if absent). One undo step. - pub fn set_attribute(&self, tag: &str, value: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetAttribute { - id: self.id, - tag: tag.to_string(), - value: value.to_string(), - })?; - Ok(()) - } - /// This viewport's view (target WCS + zoom height). Viewport-only. - pub fn viewport_view(&self) -> ApiResult<([f64; 3], f64)> { - match self - .session - .one_query(Query::GetViewportView { id: self.id })? - { - QueryResult::ViewportView { target, height } => Ok((target, height)), - _ => Err(ApiError::Transport( - "unexpected viewport-view result".into(), - )), - } - } - /// Retarget / re-zoom this viewport (one undo step). Viewport-only. - pub fn set_view(&self, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { - self.session.apply_op(Operation::SetViewportView { - id: self.id, - view_target, - view_height, - })?; - Ok(()) - } - /// The XDATA record for `application_name` on this entity (`None` if absent). - pub fn xdata(&self, application_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetXData { - id: self.id, - application_name: application_name.to_string(), - })? { - QueryResult::XData(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected xdata result".into())), - } - } - /// Attach or replace an XDATA record for `application_name` on this entity. - /// Pass `None` to remove the record (one undo step). - pub fn set_xdata(&self, application_name: &str, record: Option) -> ApiResult<()> { - self.session.apply_op(Operation::SetXData { - id: self.id, - application_name: application_name.to_string(), - record, - })?; - Ok(()) - } - pub fn as_solid(&self) -> Option { - // Typed downcast is validated by the view's kind. - matches!(self.view().ok()?.kind.as_str(), "Solid3D") - .then(|| Solid::new(self.session.clone(), self.id)) - } -} - -impl Solid { - fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - let receipt = self.session.apply_op(Operation::SolidBoolean { - op, - a: self.id, - b: other.id, - erase_sources: true, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("boolean returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn intersect(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Intersection, other) - } - pub fn union(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Union, other) - } - pub fn subtract(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Difference, other) - } - pub fn volume(&self) -> ApiResult { - match self.session.one_query(Query::GetVolume { id: self.id })? { - QueryResult::Volume(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected volume result".into())), - } - } - pub fn centroid(&self) -> ApiResult<[f64; 3]> { - match self.session.one_query(Query::GetCentroid { id: self.id })? { - QueryResult::Centroid(c) => Ok(c), - _ => Err(ApiError::Transport("unexpected centroid result".into())), - } - } - pub fn intersects(&self, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - match self.session.one_query(Query::GetIntersects { - a: self.id, - b: other.id, - })? { - QueryResult::Intersects(x) => Ok(x), - _ => Err(ApiError::Transport("unexpected intersects result".into())), - } - } -} - -impl Polyline { - pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> { - self.session.apply_op(Operation::AddVertex { - id: self.id, - at, - point, - })?; - Ok(()) - } -} - -macro_rules! text_handle { - ($name:ident) => { - impl $name { - /// The text content of this annotation. - pub fn content(&self) -> ApiResult { - match self - .session - .one_query(Query::GetTextContent { id: self.id })? - { - QueryResult::TextContent(s) => Ok(s), - _ => Err(ApiError::Transport("unexpected content result".into())), - } - } - /// Replace the text content (one undo step). - pub fn set_content(&self, value: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetTextContent { - id: self.id, - value: value.to_string(), - })?; - Ok(()) - } - } - }; -} -text_handle!(Text); -text_handle!(MText); - -// ── Collections (construction / lookup) ───────────────────────────────────── - -#[derive(Clone)] -pub struct SolidCollection { - session: Session, -} - -impl SolidCollection { - fn create(&self, prim: SolidPrimitive) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateSolid(prim))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_solid returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn create_cuboid(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { - self.create(SolidPrimitive::Cuboid { origin, size }) - } - pub fn create_sphere(&self, centre: [f64; 3], radius: f64) -> ApiResult { - self.create(SolidPrimitive::Sphere { centre, radius }) - } - pub fn create_cylinder(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { - self.create(SolidPrimitive::Cylinder { - base, - radius, - height, - }) - } - pub fn create_cone(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { - self.create(SolidPrimitive::Cone { - base, - radius, - height, - }) - } - pub fn create_torus( - &self, - centre: [f64; 3], - major_radius: f64, - minor_radius: f64, - ) -> ApiResult { - self.create(SolidPrimitive::Torus { - centre, - major_radius, - minor_radius, - }) - } - pub fn create_wedge(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { - self.create(SolidPrimitive::Wedge { origin, size }) - } - pub fn extrude(&self, profile: &impl HasId, direction: [f64; 3]) -> ApiResult { - profile.validate_session(&self.session)?; - let receipt = self.session.apply_op(Operation::Extrude { - profile: profile.id(), - direction, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("extrude returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn revolve( - &self, - profile: &impl HasId, - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, - ) -> ApiResult { - profile.validate_session(&self.session)?; - let receipt = self.session.apply_op(Operation::Revolve { - profile: profile.id(), - axis: (pivot, axis), - angle, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - /// Loft a solid through >= 2 profile entities (polylines/circles/arcs). - pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult { - for profile in profiles { - profile.validate_session(&self.session)?; - } - let ids: Vec = profiles.iter().map(|p| p.id()).collect(); - let receipt = self.session.apply_op(Operation::Loft { profiles: ids })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("loft returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } -} - -#[derive(Clone)] -pub struct CurveCollection { - session: Session, -} - -impl CurveCollection { - fn create_curve(&self, spec: Curve2Spec) -> ApiResult<(Session, ObjectId)> { - let receipt = self.session.apply_op(Operation::CreateCurve(spec))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_curve returned no id".into()))?; - Ok((self.session.clone(), id)) - } - pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: None, - })?; - Ok(Line::new(s, id)) - } - pub fn create_line_on_layer( - &self, - start: [f64; 3], - end: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: Some(layer.to_string()), - })?; - Ok(Line::new(s, id)) - } - pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: None, - })?; - Ok(Circle::new(s, id)) - } - pub fn create_circle_on_layer( - &self, - centre: [f64; 3], - radius: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: Some(layer.to_string()), - })?; - Ok(Circle::new(s, id)) - } - pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: None, - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_polyline_on_layer( - &self, - points: &[[f64; 3]], - closed: bool, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: Some(layer.to_string()), - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_point(&self, position: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: None, - })?; - Ok(Point::new(s, id)) - } - pub fn create_point_on_layer(&self, position: [f64; 3], layer: &str) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: Some(layer.to_string()), - })?; - Ok(Point::new(s, id)) - } - pub fn create_arc( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: None, - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_arc_on_layer( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: Some(layer.to_string()), - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_ellipse( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: None, - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_ellipse_on_layer( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: Some(layer.to_string()), - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_spline( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: None, - })?; - Ok(Spline::new(s, id)) - } - pub fn create_spline_on_layer( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: Some(layer.to_string()), - })?; - Ok(Spline::new(s, id)) - } - pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: None, - })?; - Ok(Ray::new(s, id)) - } - pub fn create_ray_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: Some(layer.to_string()), - })?; - Ok(Ray::new(s, id)) - } - pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: None, - })?; - Ok(XLine::new(s, id)) - } - pub fn create_xline_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: Some(layer.to_string()), - })?; - Ok(XLine::new(s, id)) - } - /// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition). - pub fn create_raster_image( - &self, - file_path: &str, - insertion_point: [f64; 3], - u_vector: [f64; 3], - v_vector: [f64; 3], - size: [f64; 2], - ) -> ApiResult { - let receipt = - self.session - .apply_op(Operation::CreateRasterImage(crate::ops::RasterImageSpec { - file_path: file_path.to_string(), - insertion_point, - u_vector, - v_vector, - size, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_raster_image returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A solid (or pattern) HATCH over a single closed polyline boundary. - pub fn create_hatch(&self, boundary: &[[f64; 2]], solid: bool) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateHatch(crate::ops::HatchSpec { - boundary: boundary.to_vec(), - solid, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A 2-line angular DIMENSION: angle between lines (vertex→first) and (vertex→second). - pub fn create_dimension_angular2ln( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionAngular2Ln( - crate::ops::DimensionAngularSpec { - vertex, - first_point, - second_point, - arc_location, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A radial DIMENSION for the circle centered at `center` through `point`. - pub fn create_dimension_radius( - &self, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - self.create_dim_radial(Operation::CreateDimensionRadius, center, point) - } - /// A diameter DIMENSION for the circle with chord points `center`/`point`. - pub fn create_dimension_diameter( - &self, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - self.create_dim_radial(Operation::CreateDimensionDiameter, center, point) - } - fn create_dim_radial( - &self, - op: impl Fn(crate::ops::DimensionRadialSpec) -> Operation, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - let receipt = self - .session - .apply_op(op(crate::ops::DimensionRadialSpec { center, point }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A 3-point angular DIMENSION (vertex + one point on each leg). - pub fn create_dimension_angular( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionAngular( - crate::ops::DimensionAngularSpec { - vertex, - first_point, - second_point, - arc_location, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A linear DIMENSION between `first_point` and `second_point`, with the - /// dimension line placed at `definition_point`. `measurement()` reads the value. - pub fn create_dimension_linear( - &self, - first_point: [f64; 3], - second_point: [f64; 3], - definition_point: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionLinear( - crate::ops::DimensionSpec { - first_point, - second_point, - definition_point, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - - /// Single-line TEXT annotation. - pub fn create_text( - &self, - value: &str, - insertion_point: [f64; 3], - height: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateText(crate::ops::TextSpec { - value: value.to_string(), - insertion_point, - height, - rotation, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_text returned no id".into()))?; - Ok(Text::new(self.session.clone(), id)) - } - /// Multi-line MTEXT annotation. - pub fn create_mtext( - &self, - value: &str, - insertion_point: [f64; 3], - height: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateMText(crate::ops::MTextSpec { - value: value.to_string(), - insertion_point, - height, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_mtext returned no id".into()))?; - Ok(MText::new(self.session.clone(), id)) - } - /// Bulk-create many points in ONE op: all-or-nothing, one undo step. - /// Places points on the current layer (`layer: None`). - pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: None, - }) - }) - .collect(); - let receipt = self.session.apply_op(Operation::CreateMany(specs))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Point::new(self.session.clone(), id)) - .collect()) - } - - /// Bulk-create many points on a specific layer in ONE op. - pub fn create_points_on_layer( - &self, - positions: &[[f64; 3]], - layer: &str, - ) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: Some(layer.to_string()), - }) - }) - .collect(); - let receipt = self.session.apply_op(Operation::CreateMany(specs))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Point::new(self.session.clone(), id)) - .collect()) - } -} - -#[derive(Clone)] -pub struct EntityCollection { - session: Session, -} - -impl EntityCollection { - /// Create a paper-space `VIEWPORT` (a `width`×`height` viewport at `center` - /// looking at `view_target` with `view_height` zoom). - pub fn create_viewport( - &self, - center: [f64; 3], - width: f64, - height: f64, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult { - let receipt = - self.session - .apply_op(Operation::CreateViewport(crate::ops::ViewportSpec { - center, - width, - height, - view_target, - view_height, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_viewport returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Create a TABLE from a grid of cell strings (`data[row][column]`), at a point. - pub fn create_table( - &self, - insertion_point: [f64; 3], - data: &[Vec], - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateTable(crate::ops::TableSpec { - insertion_point, - data: data.to_vec(), - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_table returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Create an ATTDEF (in-block attribute definition): tag/prompt/default at a point. - pub fn create_attribute_definition( - &self, - tag: &str, - prompt: &str, - default_value: &str, - insertion_point: [f64; 3], - height: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateAttributeDefinition( - crate::ops::AttributeDefinitionSpec { - tag: tag.to_string(), - prompt: prompt.to_string(), - default_value: default_value.to_string(), - insertion_point, - height, - rotation, - }, - ))?; - let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| { - ApiError::Transport("create_attribute_definition returned no id".into()) - })?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Place a block reference (`INSERT`) for `block_name` (must exist). - pub fn create_insert( - &self, - block_name: &str, - insert_point: [f64; 3], - scale: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateInsert(crate::ops::InsertSpec { - block_name: block_name.to_string(), - insert_point, - scale, - rotation, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_insert returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …). - pub fn get(&self, id: ObjectId) -> ApiResult { - // Validate existence eagerly so `get` errors surface as `UnknownId`. - self.session.one_query(Query::GetEntity { id })?; - Ok(Entity::new(self.session.clone(), id)) - } - pub fn delete(&self, id: ObjectId) -> ApiResult<()> { - self.session.apply_op(Operation::Delete { id })?; - Ok(()) - } - - /// Create a standalone `XRECORD` object. - pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateXRecord(spec.clone()))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?; - Ok(XRecord::new(self.session.clone(), id)) - } - - /// Lookup an `XRECORD` object by id and return a typed accessor handle. - pub fn get_xrecord(&self, id: ObjectId) -> ApiResult { - // Validate existence + that the payload is readable as an XRecord. - match self.session.one_query(Query::GetXRecord { id })? { - QueryResult::XRecord(_) => Ok(XRecord::new(self.session.clone(), id)), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - - /// Bulk-create many entities in ONE op (all-or-nothing, one undo step). - pub fn create_many(&self, specs: &[crate::ops::EntitySpec]) -> ApiResult> { - let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Entity::new(self.session.clone(), id)) - .collect()) - } - - /// Bulk transform (one op, all-or-nothing). - pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { - self.session.apply_op(Operation::TransformMany { - ids: ids.to_vec(), - placement, - })?; - Ok(()) - } - /// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`. - pub fn delete_many(&self, ids: &[ObjectId]) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteMany(ids.to_vec()))?; - Ok(()) - } -} - -// ── OpGroup: client-side failure-cleanup (NOT a transaction) ──────────────── - -/// Tracks handles created during a logical operation so a later failure can be -/// cleaned up with a bulk delete. Separate writes remain committed; -/// no host involvement, no rollback. -#[derive(Default)] -pub struct OpGroup { - created: Vec, - session: Option, -} - -impl OpGroup { - pub fn new() -> Self { - Self { - created: Vec::new(), - session: None, - } - } - /// Record a created handle (propagating any construction error); returns it - /// for fluent `grp.track(doc.solids().create_cuboid(..)?)?` use. - pub fn track(&mut self, handle: ApiResult) -> ApiResult { - let handle = handle?; - if let Some(session) = handle.session() { - if let Some(previous) = &self.session { - previous.same_transport(session)?; - } else { - self.session = Some(session.clone()); - } - } - if !self.created.contains(&handle.id()) { - self.created.push(handle.id()); - } - Ok(handle) - } - /// Keep everything: drop the tracking without deleting. - pub fn commit(self) {} - /// Best-effort cleanup: delete every tracked entity (one `DeleteMany` op). - /// Does not resurrect already-consumed/deleted entities. - pub fn compensate(self, doc: &Document) -> ApiResult<()> { - if let Some(session) = &self.session { - session.same_transport(&doc.session)?; - } - if self.created.is_empty() { - return Ok(()); - } - doc.entities().delete_many(&self.created) - } -} diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md deleted file mode 100644 index a0396caa1..000000000 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ /dev/null @@ -1,382 +0,0 @@ -# ocs_doc_api — API reference (auto-generated) - -Envelope version: `1` - -Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`. - -## `Solid` (acadrust `Solid3D`, collection `solids`) - -B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT). - -### Constructors (`doc.solids()`) - -- **create_cuboid**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Cuboid" -- **create_sphere**(centre: [f64; 3], radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Sphere" -- **create_cylinder**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cylinder" -- **create_cone**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cone" -- **create_torus**(centre: [f64; 3], major_radius: f64, minor_radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Torus" -- **create_wedge**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Wedge" -- **extrude**(profile: EntityRef, direction: [f64; 3]) -> `Solid` — `Extrude` -- **revolve**(profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> `Solid` — `Revolve` -- **loft**(profiles: &[EntityRef]) -> `Solid` — `Loft` - -### Methods - -- **intersect**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Intersection" -- **union**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Union" -- **subtract**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Difference" -- **volume**() -> `f64` — query `GetVolume` -- **centroid**() -> `[f64; 3]` — query `GetCentroid` -- **intersects**(other: Solid) -> `bool` — query `GetIntersects` - -## `Line` (acadrust `Line`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_line**(start: [f64; 3], end: [f64; 3]) -> `Line` — `CreateCurve`; fixed curve="Line" - -## `Circle` (acadrust `Circle`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_circle**(centre: [f64; 3], radius: f64) -> `Circle` — `CreateCurve`; fixed curve="Circle" - -## `Polyline` (acadrust `LwPolyline`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_polyline**(points: &[[f64; 3]], closed: bool) -> `Polyline` — `CreateCurve`; fixed curve="Polyline" - -### Methods - -- **add_vertex**(at: usize, point: [f64; 3]) -> `()` — op `AddVertex` - -## `Point` (acadrust `Point`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_point**(position: [f64; 3]) -> `Point` — `CreateCurve`; fixed curve="Point" -- **create_points**(positions: &[[f64; 3]]) -> `Vec` *(bulk op)* — `CreateMany`; fixed curve="Point" - -## `ArcCurve` (acadrust `Arc`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_arc**(centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> `ArcCurve` — `CreateCurve`; fixed curve="Arc" - -## `Ellipse` (acadrust `Ellipse`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_ellipse**(centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> `Ellipse` — `CreateCurve`; fixed curve="Ellipse" - -## `Spline` (acadrust `Spline`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_spline**(degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> `Spline` — `CreateCurve`; fixed curve="Spline" - -## `Ray` (acadrust `Ray`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_ray**(origin: [f64; 3], direction: [f64; 3]) -> `Ray` — `CreateCurve`; fixed curve="Ray" - -## `XLine` (acadrust `XLine`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_xline**(origin: [f64; 3], direction: [f64; 3]) -> `XLine` — `CreateCurve`; fixed curve="XLine" - -## `Text` (acadrust `Text`, collection `curves`) - -Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. - -### Constructors (`doc.curves()`) - -- **create_text**(value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Text` — `CreateText` - -### Methods - -- **content**() -> `String` — query `GetTextContent` -- **set_content**(value: &str) -> `()` — op `SetTextContent` - -## `MText` (acadrust `MText`, collection `curves`) - -Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. - -### Constructors (`doc.curves()`) - -- **create_mtext**(value: &str, insertion_point: [f64; 3], height: f64) -> `MText` — `CreateMText` - -### Methods - -- **content**() -> `String` — query `GetTextContent` -- **set_content**(value: &str) -> `()` — op `SetTextContent` - -## `Entity` (acadrust `Hatch`, collection `curves`) - -Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete. - -### Constructors (`doc.curves()`) - -- **create_hatch**(boundary: &[[f64; 2]], solid: bool) -> `Entity` — `CreateHatch` - -### Methods - -- **boundary**() -> `Vec>` — query `GetHatchBoundary` - -## `Dimension` (acadrust `Dimension`, collection `curves`) - -Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points. - -### Constructors (`doc.curves()`) - -- **create_dimension_linear**(first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> `Dimension` — `CreateDimensionLinear` -- **create_dimension_radius**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionRadius` -- **create_dimension_diameter**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionDiameter` -- **create_dimension_angular**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular` -- **create_dimension_angular2ln**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular2Ln` - -### Methods - -- **measurement**() -> `f64` — query `GetDimensionMeasurement` - -## `Entity` (acadrust `Insert`, collection `entities`) - -Block reference. Place an existing BlockRecord by name; transform moves its insert point. - -### Constructors (`doc.entities()`) - -- **create_insert**(block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> `Entity` — `CreateInsert` - -### Methods - -- **attributes**() -> `Vec<(String, String)>` — query `GetAttributes` -- **set_attribute**(tag: &str, value: &str) -> `()` — op `SetAttribute` - -## `Entity` (acadrust `AttributeDefinition`, collection `entities`) - -In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point. - -### Constructors (`doc.entities()`) - -- **create_attribute_definition**(tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Entity` — `CreateAttributeDefinition` - -## `Entity` (acadrust `Viewport`, collection `entities`) - -Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. - -### Constructors (`doc.entities()`) - -- **create_viewport**(center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> `Entity` — `CreateViewport` - -### Methods - -- **viewport_view**() -> `([f64; 3], f64)` — query `GetViewportView` -- **set_view**(view_target: [f64; 3], view_height: f64) -> `()` — op `SetViewportView` - -## `Entity` (acadrust `RasterImage`, collection `entities`) - -Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete. - -### Constructors (`doc.entities()`) - -- **create_raster_image**(file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]) -> `Entity` — `CreateRasterImage` - -## `Entity` (acadrust `Table`, collection `entities`) - -Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete. - -### Constructors (`doc.entities()`) - -- **create_table**(insertion_point: [f64; 3], data: &[Vec]) -> `Entity` — `CreateTable` - -## `XRecord` (acadrust `XRecord`, collection `entities`) - -Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload. - -### Constructors (`doc.entities()`) - -- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord` - -### Methods - -- **payload**() -> `XRecordSpec` — query `GetXRecord` -- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord` - -## `Layer` (acadrust `-`, collection `layers`) - -Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers(). - -### Methods - -- **list**() -> `Vec` — query `ListLayers` -- **create**(info: LayerInfo) -> `()` — op `CreateLayer` -- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer` -- **delete**(name: String) -> `()` — op `DeleteLayer` - -## Generic methods (every handle) - -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, -`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a -`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - -- **`id()` -> `ObjectId`** — the stable entity identity. -- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## Collections & cross-cutting - -- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. -- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, - `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only - batch, one round-trip, **no revision bump**). -- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, - `transform_many(&ids, placement)`, `delete_many(&ids)`. -- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / - `compensate`). **Not** a transaction: no atomicity, no rollback. - -## Bulk ops — single op, all-or-nothing, item-capped - -`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo -step, one publish, and **all-or-nothing via upfront validation** (no rollback). -Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for -bulk data (N ≳ a few hundred); per-op autocommit is the right tool for -modeling-scale work. - -## Semantics - -- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host - call = one undo step. Multi-op write transactions were removed (security/ - integrity review). -- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a - failed op applies nothing and records no undo step. `erase_sources` booleans run - only on success. -- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, - Transport}`; bulk validation names the failing index; a stale handle returns - `UnknownId` (never a panic). - -## Kernel mapping - -| op | kernel call | returns | -|---|---|---| -| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option | -| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option | -| SolidBoolean | `cadkernel::brep::combine` | Result | -| Transform | `cadkernel::brep::transform` | Option | -| GetBounds | `cadkernel::brep::body_bounds` | Option | -| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed | - -## Wire vocabulary - -Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch. - -### `Operation` - -- `CreateSolid(SolidPrimitive)` -- `CreateCurve(Curve2Spec)` -- `Extrude { profile: ObjectId, direction: [f64; 3] }` -- `Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }` -- `Transform { id: ObjectId, placement: PlacementSpec }` -- `SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }` -- `AddVertex { id: ObjectId, at: usize, point: [f64; 3] }` -- `Delete { id: ObjectId }` -- `CreateMany(Vec)` -- `TransformMany { ids: Vec, placement: PlacementSpec }` -- `DeleteMany(Vec)` -- `CreateInsert(InsertSpec)` -- `CreateViewport(ViewportSpec)` -- `CreateText(TextSpec)` -- `CreateMText(MTextSpec)` -- `SetTextContent { id: ObjectId, value: String }` -- `CreateHatch(HatchSpec)` -- `CreateDimensionLinear(DimensionSpec)` -- `SetAttribute { id: ObjectId, tag: String, value: String }` -- `SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }` -- `CreateRasterImage(RasterImageSpec)` -- `Loft { profiles: Vec }` -- `CreateDimensionRadius(DimensionRadialSpec)` -- `CreateDimensionDiameter(DimensionRadialSpec)` -- `CreateDimensionAngular(DimensionAngularSpec)` -- `CreateAttributeDefinition(AttributeDefinitionSpec)` -- `CreateTable(TableSpec)` -- `CreateDimensionAngular2Ln(DimensionAngularSpec)` -- `SetXData { id: ObjectId, application_name: String, record: Option }` -- `CreateXRecord(XRecordSpec)` -- `SetXRecord { id: ObjectId, spec: XRecordSpec }` -- `CreateLayer(LayerInfo)` -- `UpdateLayer { name: String, info: LayerInfo }` -- `DeleteLayer { name: String }` -- `SetEntityLayer { id: ObjectId, layer: String }` -- `SetXDataMany(Vec<(ObjectId, String, Option)` - -### `Query` - -- `GetEntity { id: ObjectId }` -- `GetBounds { id: ObjectId }` -- `GetCentroid { id: ObjectId }` -- `GetVolume { id: ObjectId }` -- `GetIntersects { a: ObjectId, b: ObjectId }` -- `GetGeometryRevision` -- `GetTextContent { id: ObjectId }` -- `GetHatchBoundary { id: ObjectId }` -- `GetDimensionMeasurement { id: ObjectId }` -- `GetAttributes { id: ObjectId }` -- `GetBlockEntities { block_name: String }` -- `GetViewportView { id: ObjectId }` -- `GetXData { id: ObjectId, application_name: String }` -- `GetXRecord { id: ObjectId }` -- `ListLayers` -- `GetEntityLayer { id: ObjectId }` - -## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `Transport(String)` | The channel failed (disconnected / oversized / timeout). | - -`ApiError` is serializable, so IPC returns the same structured error the in-process -executor produced. - -## Envelope & transport - -`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or -`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's -envelope version (bridges must check it). Transports: `InProcess` (host) and -`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: -Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries -`outcome` (per-op result), `query_results`, and `new_revision`. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json deleted file mode 100644 index 04697d696..000000000 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ /dev/null @@ -1,1349 +0,0 @@ -{ - "envelope_version": 1, - "families": [ - { - "acadrust_variant": "Solid3D", - "collection": "solids", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cuboid" - }, - "name": "create_cuboid", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Sphere" - }, - "name": "create_sphere", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cylinder" - }, - "name": "create_cylinder", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cone" - }, - "name": "create_cone", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_radius", - "ty": "f64" - }, - { - "name": "minor_radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Torus" - }, - "name": "create_torus", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Wedge" - }, - "name": "create_wedge", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "extrude", - "op": "Extrude", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "pivot", - "ty": "[f64; 3]" - }, - { - "name": "axis", - "ty": "[f64; 3]" - }, - { - "name": "angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "revolve", - "op": "Revolve", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profiles", - "ty": "&[EntityRef]" - } - ], - "bulk": false, - "fixed": null, - "name": "loft", - "op": "Loft", - "returns": "Solid" - } - ], - "handle": "Solid", - "methods": [ - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Intersection" - }, - "kind": "op", - "name": "intersect", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Union" - }, - "kind": "op", - "name": "union", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Difference" - }, - "kind": "op", - "name": "subtract", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "volume", - "op": null, - "query": "GetVolume", - "read_only": true, - "returns": "f64" - }, - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "centroid", - "op": null, - "query": "GetCentroid", - "read_only": true, - "returns": "[f64; 3]" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "intersects", - "op": null, - "query": "GetIntersects", - "read_only": true, - "returns": "bool" - } - ], - "name": "Solid" - }, - { - "acadrust_variant": "Line", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "start", - "ty": "[f64; 3]" - }, - { - "name": "end", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Line" - }, - "name": "create_line", - "op": "CreateCurve", - "returns": "Line" - } - ], - "handle": "Line", - "methods": [], - "name": "Line" - }, - { - "acadrust_variant": "Circle", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Circle" - }, - "name": "create_circle", - "op": "CreateCurve", - "returns": "Circle" - } - ], - "handle": "Circle", - "methods": [], - "name": "Circle" - }, - { - "acadrust_variant": "LwPolyline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "points", - "ty": "&[[f64; 3]]" - }, - { - "name": "closed", - "ty": "bool" - } - ], - "bulk": false, - "fixed": { - "curve": "Polyline" - }, - "name": "create_polyline", - "op": "CreateCurve", - "returns": "Polyline" - } - ], - "handle": "Polyline", - "methods": [ - { - "args": [ - { - "name": "at", - "ty": "usize" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "add_vertex", - "op": "AddVertex", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Polyline" - }, - { - "acadrust_variant": "Point", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "position", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Point" - }, - "name": "create_point", - "op": "CreateCurve", - "returns": "Point" - }, - { - "args": [ - { - "name": "positions", - "ty": "&[[f64; 3]]" - } - ], - "bulk": true, - "fixed": { - "curve": "Point" - }, - "name": "create_points", - "op": "CreateMany", - "returns": "Vec" - } - ], - "handle": "Point", - "methods": [], - "name": "Point" - }, - { - "acadrust_variant": "Arc", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "start_angle", - "ty": "f64" - }, - { - "name": "end_angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Arc" - }, - "name": "create_arc", - "op": "CreateCurve", - "returns": "ArcCurve" - } - ], - "handle": "ArcCurve", - "methods": [], - "name": "Arc" - }, - { - "acadrust_variant": "Ellipse", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_axis", - "ty": "[f64; 3]" - }, - { - "name": "ratio", - "ty": "f64" - }, - { - "name": "start", - "ty": "f64" - }, - { - "name": "end", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Ellipse" - }, - "name": "create_ellipse", - "op": "CreateCurve", - "returns": "Ellipse" - } - ], - "handle": "Ellipse", - "methods": [], - "name": "Ellipse" - }, - { - "acadrust_variant": "Spline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "degree", - "ty": "i32" - }, - { - "name": "control_points", - "ty": "&[[f64; 3]]" - }, - { - "name": "knots", - "ty": "&[f64]" - }, - { - "name": "weights", - "ty": "&[f64]" - } - ], - "bulk": false, - "fixed": { - "curve": "Spline" - }, - "name": "create_spline", - "op": "CreateCurve", - "returns": "Spline" - } - ], - "handle": "Spline", - "methods": [], - "name": "Spline" - }, - { - "acadrust_variant": "Ray", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Ray" - }, - "name": "create_ray", - "op": "CreateCurve", - "returns": "Ray" - } - ], - "handle": "Ray", - "methods": [], - "name": "Ray" - }, - { - "acadrust_variant": "XLine", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "XLine" - }, - "name": "create_xline", - "op": "CreateCurve", - "returns": "XLine" - } - ], - "handle": "XLine", - "methods": [], - "name": "XLine" - }, - { - "acadrust_variant": "Text", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_text", - "op": "CreateText", - "returns": "Text" - } - ], - "handle": "Text", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "content", - "op": null, - "query": "GetTextContent", - "read_only": true, - "returns": "String" - }, - { - "args": [ - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_content", - "op": "SetTextContent", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Text" - }, - { - "acadrust_variant": "MText", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_mtext", - "op": "CreateMText", - "returns": "MText" - } - ], - "handle": "MText", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "content", - "op": null, - "query": "GetTextContent", - "read_only": true, - "returns": "String" - }, - { - "args": [ - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_content", - "op": "SetTextContent", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "MText" - }, - { - "acadrust_variant": "Hatch", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "boundary", - "ty": "&[[f64; 2]]" - }, - { - "name": "solid", - "ty": "bool" - } - ], - "bulk": false, - "fixed": null, - "name": "create_hatch", - "op": "CreateHatch", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "boundary", - "op": null, - "query": "GetHatchBoundary", - "read_only": true, - "returns": "Vec>" - } - ], - "name": "Hatch" - }, - { - "acadrust_variant": "Dimension", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "definition_point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_linear", - "op": "CreateDimensionLinear", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_radius", - "op": "CreateDimensionRadius", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_diameter", - "op": "CreateDimensionDiameter", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular", - "op": "CreateDimensionAngular", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular2ln", - "op": "CreateDimensionAngular2Ln", - "returns": "Dimension" - } - ], - "handle": "Dimension", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "measurement", - "op": null, - "query": "GetDimensionMeasurement", - "read_only": true, - "returns": "f64" - } - ], - "name": "Dimension" - }, - { - "acadrust_variant": "Insert", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "block_name", - "ty": "&str" - }, - { - "name": "insert_point", - "ty": "[f64; 3]" - }, - { - "name": "scale", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_insert", - "op": "CreateInsert", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "attributes", - "op": null, - "query": "GetAttributes", - "read_only": true, - "returns": "Vec<(String, String)>" - }, - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_attribute", - "op": "SetAttribute", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Insert" - }, - { - "acadrust_variant": "AttributeDefinition", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "prompt", - "ty": "&str" - }, - { - "name": "default_value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_attribute_definition", - "op": "CreateAttributeDefinition", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "AttributeDefinition" - }, - { - "acadrust_variant": "Viewport", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "width", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_viewport", - "op": "CreateViewport", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "viewport_view", - "op": null, - "query": "GetViewportView", - "read_only": true, - "returns": "([f64; 3], f64)" - }, - { - "args": [ - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_view", - "op": "SetViewportView", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Viewport" - }, - { - "acadrust_variant": "RasterImage", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "file_path", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "u_vector", - "ty": "[f64; 3]" - }, - { - "name": "v_vector", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 2]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_raster_image", - "op": "CreateRasterImage", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "RasterImage" - }, - { - "acadrust_variant": "Table", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "data", - "ty": "&[Vec]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_table", - "op": "CreateTable", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "Table" - }, - { - "acadrust_variant": "XRecord", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "name": "create_xrecord", - "op": "CreateXRecord", - "returns": "XRecord" - } - ], - "handle": "XRecord", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "payload", - "op": null, - "query": "GetXRecord", - "read_only": true, - "returns": "XRecordSpec" - }, - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_payload", - "op": "SetXRecord", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "XRecord" - }, - { - "acadrust_variant": null, - "collection": "layers", - "constructors": [], - "handle": "Layer", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "list", - "op": null, - "query": "ListLayers", - "read_only": true, - "returns": "Vec" - }, - { - "args": [ - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "create", - "op": "CreateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - }, - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "update", - "op": "UpdateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "delete", - "op": "DeleteLayer", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Layer" - } - ], - "layouts": { - "ObjectId": { - "struct": [ - { - "name": "0", - "type": "u64" - } - ] - }, - "Operation": { - "enum": [ - "CreateSolid(SolidPrimitive)", - "CreateCurve(Curve2Spec)", - "Extrude { profile: ObjectId, direction: [f64; 3] }", - "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", - "Transform { id: ObjectId, placement: PlacementSpec }", - "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", - "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", - "Delete { id: ObjectId }", - "CreateMany(Vec)", - "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)", - "CreateInsert(InsertSpec)", - "CreateViewport(ViewportSpec)", - "CreateText(TextSpec)", - "CreateMText(MTextSpec)", - "SetTextContent { id: ObjectId, value: String }", - "CreateHatch(HatchSpec)", - "CreateDimensionLinear(DimensionSpec)", - "SetAttribute { id: ObjectId, tag: String, value: String }", - "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", - "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "GetEntity { id: ObjectId }", - "GetBounds { id: ObjectId }", - "GetCentroid { id: ObjectId }", - "GetVolume { id: ObjectId }", - "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision", - "GetTextContent { id: ObjectId }", - "GetHatchBoundary { id: ObjectId }", - "GetDimensionMeasurement { id: ObjectId }", - "GetAttributes { id: ObjectId }", - "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } - }, - "object_model": { - "collections": [ - "solids", - "curves", - "entities" - ], - "document": "Document", - "handles": [ - "Entity", - "Solid", - "Line", - "Circle", - "Polyline", - "Point", - "ArcCurve", - "Ellipse", - "Spline", - "Ray", - "XLine", - "Text", - "MText", - "Dimension", - "XRecord", - "Layer" - ], - "root": "DocApi" - }, - "schema": "ocs_doc_api/binding_schema" -} \ No newline at end of file diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json deleted file mode 100644 index b43755336..000000000 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "ObjectId": { "struct": [ { "name": "0", "type": "u64" } ] }, - "Operation": { - "enum": [ - "CreateSolid(SolidPrimitive)", - "CreateCurve(Curve2Spec)", - "Extrude { profile: ObjectId, direction: [f64; 3] }", - "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", - "Transform { id: ObjectId, placement: PlacementSpec }", - "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", - "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", - "Delete { id: ObjectId }", - "CreateMany(Vec)", - "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)", - "CreateInsert(InsertSpec)", - "CreateViewport(ViewportSpec)", - "CreateText(TextSpec)", - "CreateMText(MTextSpec)", - "SetTextContent { id: ObjectId, value: String }", - "CreateHatch(HatchSpec)", - "CreateDimensionLinear(DimensionSpec)", - "SetAttribute { id: ObjectId, tag: String, value: String }", - "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", - "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "GetEntity { id: ObjectId }", - "GetBounds { id: ObjectId }", - "GetCentroid { id: ObjectId }", - "GetVolume { id: ObjectId }", - "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision", - "GetTextContent { id: ObjectId }", - "GetHatchBoundary { id: ObjectId }", - "GetDimensionMeasurement { id: ObjectId }", - "GetAttributes { id: ObjectId }", - "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } -} diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs deleted file mode 100644 index 7d8e91d22..000000000 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ /dev/null @@ -1,127 +0,0 @@ -// HAND-MAINTAINED wire vocabulary (the Operation enum). This file is NOT derived -// from spec/entities.toml by build.rs — the enum is the canonical append-only -// wire vocabulary; the spec's `op` names must each map to a variant here -// (enforced by a test). Append new variants at the END only (bincode discriminant -// stability). Do NOT reorder or remove variants. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::ops::{ - AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, - DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, -}; - -/// A typed write operation. Each variant is ONE atomic host call = one -/// undo step. Append new variants at the END only. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Operation { - // construction - CreateSolid(SolidPrimitive), - CreateCurve(Curve2Spec), - Extrude { - profile: ObjectId, - direction: [f64; 3], - }, - Revolve { - profile: ObjectId, - axis: ([f64; 3], [f64; 3]), - angle: f64, - }, - // modification - Transform { - id: ObjectId, - placement: PlacementSpec, - }, - SolidBoolean { - op: BoolOp, - a: ObjectId, - b: ObjectId, - erase_sources: bool, - }, - AddVertex { - id: ObjectId, - at: usize, - point: [f64; 3], - }, - Delete { - id: ObjectId, - }, - // Bulk ops (single op, upfront-validation all-or-nothing — NOT a transaction, §5.3) - CreateMany(Vec), - TransformMany { - ids: Vec, - placement: PlacementSpec, - }, - DeleteMany(Vec), - // containers - CreateInsert(InsertSpec), - // paper-space - CreateViewport(ViewportSpec), - // annotations - CreateText(TextSpec), - CreateMText(MTextSpec), - SetTextContent { - id: ObjectId, - value: String, - }, - // hatch - CreateHatch(HatchSpec), - // dimensions - CreateDimensionLinear(DimensionSpec), - // attributes - SetAttribute { - id: ObjectId, - tag: String, - value: String, - }, - // viewport view - SetViewportView { - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - }, - // media - CreateRasterImage(RasterImageSpec), - // multi-profile solid - Loft { - profiles: Vec, - }, - // dimension sub-types - CreateDimensionRadius(DimensionRadialSpec), - CreateDimensionDiameter(DimensionRadialSpec), - CreateDimensionAngular(DimensionAngularSpec), - // attribute definitions - CreateAttributeDefinition(AttributeDefinitionSpec), - // tables - CreateTable(TableSpec), - // dimension sub-types (2-line angular) - CreateDimensionAngular2Ln(DimensionAngularSpec), - // xdata / xrecord roundtrip - SetXData { - id: ObjectId, - application_name: String, - record: Option, - }, - SetXDataMany(Vec<(ObjectId, String, Option)>), - CreateXRecord(XRecordSpec), - SetXRecord { - id: ObjectId, - spec: XRecordSpec, - }, - // layer CRUD - CreateLayer(crate::ops::LayerInfo), - UpdateLayer { - name: String, - info: crate::ops::LayerInfo, - }, - DeleteLayer { - name: String, - }, - SetEntityLayer { - id: ObjectId, - layer: String, - }, -} diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs deleted file mode 100644 index f58c6a0c8..000000000 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ /dev/null @@ -1,89 +0,0 @@ -// HAND-MAINTAINED wire vocabulary (the Query enum). NOT derived from the spec by -// build.rs — the enum is the canonical append-only wire vocabulary; the spec's -// `query` names must each map to a variant here (enforced by a test). Append new -// variants at the END only (bincode discriminant stability). - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; - -/// A typed read query. Read-only: no mutation, no undo, no revision -/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. -/// (Not `Copy`: some variants carry owned data like a block name.) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Query { - GetEntity { - id: ObjectId, - }, - GetBounds { - id: ObjectId, - }, - GetCentroid { - id: ObjectId, - }, - GetVolume { - id: ObjectId, - }, - /// Whether two entities' bounds overlap (two-phase conditional modelling). - GetIntersects { - a: ObjectId, - b: ObjectId, - }, - GetGeometryRevision, - /// The text content of a Text/MText annotation. - GetTextContent { - id: ObjectId, - }, - /// The boundary loops of a Hatch (outer + islands) as polylines. - GetHatchBoundary { - id: ObjectId, - }, - /// The measured value of a Dimension (distance or angle). - GetDimensionMeasurement { - id: ObjectId, - }, - /// An insert's attributes as (tag, value) pairs. - GetAttributes { - id: ObjectId, - }, - /// The entity ids + kinds inside a block definition (read-only traversal). - GetBlockEntities { - block_name: String, - }, - /// A viewport's view (target + zoom height). - GetViewportView { - id: ObjectId, - }, - /// The XDATA record for `application_name` attached to an entity (`None` if absent). - GetXData { - id: ObjectId, - application_name: String, - }, - /// The XRECORD object payload by id. - GetXRecord { - id: ObjectId, - }, - /// List all layers in the document. - ListLayers, - /// The layer name of an entity. - GetEntityLayer { - id: ObjectId, - }, - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` populates `EntityView::bounds`; leave it false for a - /// lightweight list view. - EnumerateEntities { - kind: Option, - layer: Option, - include_bounds: bool, - }, - /// The WCS location of a Point entity. - GetPointPosition { - id: ObjectId, - }, - /// The start and end WCS points of a Line entity. - GetLineGeometry { - id: ObjectId, - }, -} diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs deleted file mode 100644 index e77086d51..000000000 --- a/crates/ocs_doc_api/src/geom.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Kernel geometry helpers (host feature, plan §10 code-mapping table). Thin -//! wrappers over `cadkernel::brep::*` mapping the crate's plain-data DTOs to the -//! non-serde kernel types. Kernel keys never cross the API (decision #7). - -use cadkernel::geom2d::Curve as Curve2; -use cadkernel::space::Plane; - -use crate::backend::KernelBody; -use crate::error::{ApiError, ApiResult, GeometryErrorKind}; -use crate::ops::{BoolOp, SolidPrimitive}; - -fn geom_err(kind: GeometryErrorKind, msg: impl Into) -> ApiError { - ApiError::geometry(kind, msg) -} - -/// Signed volume and centroid of a closed triangle mesh via the divergence -/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source -/// of truth shared by the host backend and the test/example mock backends. -pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - mesh.mass_properties().unwrap_or((0.0, [0.0; 3])) -} - -pub fn validate_placement(p: &crate::PlacementSpec) -> ApiResult<()> { - let placement = cadkernel::brep::Placement { - origin: p.origin, - x_axis: p.x_axis, - y_axis: p.y_axis, - z_axis: p.z_axis, - }; - if placement.scale().is_none() || p.origin.iter().any(|v| !v.is_finite()) { - return Err(ApiError::validation( - "Transform", - "placement must be a finite, nonzero similarity", - )); - } - Ok(()) -} - -/// Construct a solid primitive. -pub fn make_solid(p: &SolidPrimitive) -> ApiResult { - crate::validation::solid(p)?; - use cadkernel::brep::make; - let body = match *p { - SolidPrimitive::Cuboid { origin, size } => make::cuboid(origin, size), - SolidPrimitive::Sphere { centre, radius } => make::sphere(centre, radius), - SolidPrimitive::Cylinder { - base, - radius, - height, - } => make::cylinder(base, radius, height), - SolidPrimitive::Cone { - base, - radius, - height, - } => make::cone(base, radius, height), - SolidPrimitive::Torus { - centre, - major_radius, - minor_radius, - } => make::torus(centre, major_radius, minor_radius), - SolidPrimitive::Wedge { origin, size } => make::wedge(origin, size[0], size[1], size[2]), - }; - body.ok_or_else(|| { - geom_err( - GeometryErrorKind::InvalidInput, - "kernel make returned empty body", - ) - }) -} - -/// Boolean combine (pure): `brep::combine(a, b, op, operation_tolerance(&[&a,&b]))`. -/// Maps the kernel `Snag` to `ApiError::Geometry`. Does NOT mutate the inputs. -pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult { - let kernel_op = crate::executor::kernel_bool_op(op); - let tol = cadkernel::brep::operation_tolerance(&[a, b]); - cadkernel::brep::combine(a.clone(), b.clone(), kernel_op, tol) - .map_err(|e| geom_err(GeometryErrorKind::BooleanFailed, format!("{e:?}"))) -} - -/// Extrude a closed 2D profile into a solid (`brep::extrude`). -pub(crate) fn extrude( - plane: Plane, - profile: &[Curve2], - direction: [f64; 3], -) -> ApiResult { - cadkernel::brep::extrude(plane, profile, direction) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed")) -} - -/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`). -pub(crate) fn revolve( - plane: Plane, - profile: &[Curve2], - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, -) -> ApiResult { - cadkernel::brep::revolve(plane, profile, pivot, axis, angle) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed")) -} diff --git a/crates/ocs_doc_api/src/id.rs b/crates/ocs_doc_api/src/id.rs deleted file mode 100644 index 87f4de167..000000000 --- a/crates/ocs_doc_api/src/id.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Stable entity identity. - -use serde::{Deserialize, Serialize}; - -/// A stable u64 handle to a first-level document entity, decoupled from any -/// internal engine/kernel pointer. Value-compatible with `acadrust::Handle` — -/// kernel keys never cross the API boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ObjectId(pub u64); - -impl ObjectId { - /// The null / absent handle (`acadrust::Handle::NULL` value `0`). - pub const NULL: ObjectId = ObjectId(0); - - pub const fn as_u64(self) -> u64 { - self.0 - } - - /// Build from a raw u64 (e.g. an `acadrust::Handle`'s value). - pub const fn from_u64(v: u64) -> Self { - Self(v) - } - - /// Build from an `acadrust::Handle` (host/ipc feature). - #[cfg(any(feature = "host", feature = "ipc"))] - pub fn from_handle(h: acadrust::Handle) -> Self { - Self(h.value()) - } - - /// Convert to an `acadrust::Handle` (host/ipc feature). - #[cfg(any(feature = "host", feature = "ipc"))] - pub fn to_handle(self) -> acadrust::Handle { - acadrust::Handle::new(self.0) - } -} diff --git a/crates/ocs_doc_api/src/layer.rs b/crates/ocs_doc_api/src/layer.rs deleted file mode 100644 index 48987f24f..000000000 --- a/crates/ocs_doc_api/src/layer.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Shared layer-name normalization rules. -//! -//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing -//! whitespace is ignored, and empty names are invalid. The host (`normalize_name` -//! in `acadrust::tables`) implements the canonical rule set; this module re-exports -//! a compatible, dependency-free function for the wire side and for backends that -//! do not link against `acadrust` directly. - -/// Normalize a raw layer name into its case-insensitive lookup key. -/// -/// This mirrors `acadrust::tables::normalize_name` used by the host: -/// - trims leading and trailing ASCII whitespace -/// - converts the result to uppercase -/// - returns `""` if the name is empty after trimming -pub fn normalize_layer_name(name: &str) -> String { - name.trim().to_ascii_uppercase() -} - -/// A normalized layer name is non-empty and matches the host lookup key. -pub fn is_valid_layer_name(name: &str) -> bool { - !normalize_layer_name(name).is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalization_trims_and_uppercases() { - assert_eq!(normalize_layer_name(" Walls "), "WALLS"); - assert_eq!(normalize_layer_name("walls"), "WALLS"); - assert_eq!(normalize_layer_name("WALLS"), "WALLS"); - } - - #[test] - fn empty_and_whitespace_names_are_invalid() { - assert_eq!(normalize_layer_name(""), ""); - assert_eq!(normalize_layer_name(" "), ""); - assert!(!is_valid_layer_name("")); - assert!(!is_valid_layer_name(" ")); - } -} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs deleted file mode 100644 index 649f4bc84..000000000 --- a/crates/ocs_doc_api/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Typed document operations, queries and facades over in-process or IPC transports. - -pub mod error; -pub mod id; -pub mod revision; - -#[cfg(feature = "host")] -pub mod backend; -#[cfg(feature = "host")] -pub mod convert; -#[cfg(feature = "host")] -pub mod executor; -#[cfg(feature = "host")] -pub mod geom; -#[cfg(feature = "host")] -mod validation; - -pub mod envelope; -pub mod facade; -pub mod layer; -pub mod ops; -pub mod query; -pub mod transport; - -// Append-only wire enums, maintained alongside the DTOs and spec. -pub mod gen { - pub mod ops { - include!("gen/ops_gen.rs"); - } - pub mod query { - include!("gen/query_gen.rs"); - } - pub use ops::Operation; - pub use query::Query; -} - -pub use error::{ApiError, ApiResult, GeometryErrorKind}; -pub use id::ObjectId; -pub use revision::GeometryRevision; - -pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; -pub use facade::{ - ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, - EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, - Solid, SolidCollection, Spline, Text, XLine, XRecord, -}; -pub use layer::{is_valid_layer_name, normalize_layer_name}; -pub use ops::{ - BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation, - PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry, - XRecordSpec, XRecordValue, -}; -pub use query::{Aabb, EntityView, Query, QueryResult}; - -#[cfg(feature = "host")] -pub use convert::{ - bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, - entity_to_profile_curves, transform_entity_geometry, -}; -#[cfg(feature = "doc_api_host")] -pub use transport::ipc::doc_api_for_host; - -pub use transport::Transport; - -/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). -/// Bump when the envelope layout or `Operation`/`Query` discriminants change. -/// Bridges must check this before speaking (see `bindings/README.md`). -pub const ENVELOPE_VERSION: u16 = 1; - -/// The generated binding handover schema: object model + method -/// signatures + op/query mapping with curated wire vocabulary. Single, -/// self-contained, versioned with [`ENVELOPE_VERSION`]. -pub fn binding_schema_json() -> &'static str { - include_str!("gen/binding_schema.json") -} diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs deleted file mode 100644 index 7941b4112..000000000 --- a/crates/ocs_doc_api/src/ops.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Write operations — the typed, transport-agnostic vocabulary for changing the -//! document. The `Operation` enum is **hand-maintained** (append-only) -//! in `src/gen/ops_gen.rs` — it is NOT derived from the spec by build.rs; the -//! spec's `op` names must each map to a variant here (asserted by a test). This -//! module re-exports it and holds the hand-written payload mirrors. - -use serde::{Deserialize, Serialize}; - -pub use crate::gen::Operation; - -/// An XDATA record payload: one registered application name plus its typed -/// value list. This is a plain-data, serde-compatible mirror of -/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of -/// the host dependency. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XDataRecord { - pub application_name: String, - pub values: Vec, -} - -/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XDataValue { - String(String), - ControlString(String), - LayerName(String), - BinaryData(Vec), - Handle(u64), - Point3D([f64; 3]), - Position3D([f64; 3]), - Displacement3D([f64; 3]), - Direction3D([f64; 3]), - Real(f64), - Distance(f64), - ScaleFactor(f64), - Integer16(i16), - Integer32(i32), -} - -/// Construction spec for an `XRECORD` object. Mirrors the acadrust -/// `XRecord` payload (name + cloning flags + group-code entries) in a -/// serde-compatible, host-independent form. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordSpec { - pub name: String, - pub cloning_flags: XRecordCloningFlags, - pub entries: Vec, -} - -/// XRecord cloning behavior flags. Mirror of -/// `acadrust::objects::DictionaryCloningFlags`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum XRecordCloningFlags { - #[default] - NotApplicable, - KeepExisting, - UseClone, - XrefName, - Name, - UnmangleName, -} - -/// One XRECORD group-code entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordEntry { - pub code: i32, - pub value: XRecordValue, -} - -/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XRecordValue { - String(String), - Double(f64), - Int16(i16), - Int32(i32), - Int64(i64), - Byte(u8), - Bool(bool), - Handle(u64), - Point3D([f64; 3]), - Chunk(Vec), -} - -/// Boolean operation kind for [`Operation::SolidBoolean`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BoolOp { - Union, - Intersection, - Difference, -} - -/// A plain-data color value. Mirrors `acadrust::types::Color` for wire -/// independence from the host dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Color { - ByLayer, - None, - ByBlock, - Index(u8), - Rgb { r: u8, g: u8, b: u8 }, -} - -impl Color { - pub const WHITE: Color = Color::Index(7); -} - -/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum LineWeight { - #[default] - ByLayer, - ByBlock, - Default, - /// Specific line weight in 1/100 mm. - Value(i16), -} - -/// Layer visibility/lock flags. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct LayerFlags { - pub frozen: bool, - pub locked: bool, - pub frozen_in_new_viewport: bool, - pub off: bool, -} - -/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct LayerInfo { - pub name: String, - pub flags: LayerFlags, - pub color: Color, - pub line_type: String, - pub line_weight: LineWeight, - pub plot_style: String, - pub is_plottable: bool, -} - -impl LayerInfo { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - flags: LayerFlags::default(), - color: Color::WHITE, - line_type: "Continuous".to_string(), - line_weight: LineWeight::Default, - plot_style: String::new(), - is_plottable: true, - } - } - - /// The normalized (trimmed + uppercased) lookup key for this layer name. - pub fn normalized_name(&self) -> String { - crate::layer::normalize_layer_name(&self.name) - } -} - -/// Construction spec for a B-rep solid primitive (plain-data mirror of the -/// non-serde `cadkernel::brep::make::*` arguments). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum SolidPrimitive { - Cuboid { - origin: [f64; 3], - size: [f64; 3], - }, - Sphere { - centre: [f64; 3], - radius: f64, - }, - Cylinder { - base: [f64; 3], - radius: f64, - height: f64, - }, - Cone { - base: [f64; 3], - radius: f64, - height: f64, - }, - Torus { - centre: [f64; 3], - major_radius: f64, - minor_radius: f64, - }, - Wedge { - origin: [f64; 3], - size: [f64; 3], - }, -} - -/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Curve2Spec { - Line { - start: [f64; 3], - end: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Circle { - centre: [f64; 3], - radius: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Polyline { - points: Vec<[f64; 3]>, - closed: bool, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Point { - position: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). - Arc { - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; - /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). - Ellipse { - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// NURBS spline (control points + knots + weights; `degree` typically 3). - Spline { - degree: i32, - control_points: Vec<[f64; 3]>, - knots: Vec, - weights: Vec, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// A ray from `origin` along `direction` (bounded at origin only). - Ray { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// An infinite construction line through `origin` along `direction`. - XLine { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, -} - -impl Curve2Spec { - /// The target layer name, if any. - pub fn layer(&self) -> Option<&str> { - match self { - Curve2Spec::Line { layer, .. } - | Curve2Spec::Circle { layer, .. } - | Curve2Spec::Polyline { layer, .. } - | Curve2Spec::Point { layer, .. } - | Curve2Spec::Arc { layer, .. } - | Curve2Spec::Ellipse { layer, .. } - | Curve2Spec::Spline { layer, .. } - | Curve2Spec::Ray { layer, .. } - | Curve2Spec::XLine { layer, .. } => layer.as_deref(), - } - } -} - -/// A generic entity-construction payload used by the bulk op -/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction -/// specs so one bulk op can carry many homogeneous (or mixed) creations. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum EntitySpec { - Curve(Curve2Spec), - Solid(SolidPrimitive), -} - -impl EntitySpec { - /// The target layer name for this spec, if explicitly set. - pub fn layer(&self) -> Option<&str> { - match self { - EntitySpec::Curve(c) => c.layer(), - EntitySpec::Solid(_) => None, - } - } -} - -/// Construction spec for a block reference (`INSERT`): place the block -/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform -/// `scale` and `rotation` (radians, about Z). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InsertSpec { - pub block_name: String, - pub insert_point: [f64; 3], - pub scale: f64, - pub rotation: f64, -} - -/// Construction spec for a paper-space `VIEWPORT`: a `width`×`height` viewport -/// centered at `center` (paper space), looking at `view_target` (model space, -/// WCS) with `view_height` (model-space height visible; the viewport's zoom). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ViewportSpec { - pub center: [f64; 3], - pub width: f64, - pub height: f64, - pub view_target: [f64; 3], - pub view_height: f64, -} - -/// Construction spec for a single-line `TEXT` annotation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TextSpec { - pub value: String, - pub insertion_point: [f64; 3], - pub height: f64, - pub rotation: f64, -} - -/// Construction spec for a multi-line `MTEXT` annotation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MTextSpec { - pub value: String, - pub insertion_point: [f64; 3], - pub height: f64, -} - -/// Construction spec for a `HATCH` with a single closed polyline boundary -/// (straight segments). `solid` selects solid fill vs a pattern (pattern naming -/// is a later refinement). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct HatchSpec { - /// Closed boundary as an ordered polyline (first==last implied; closed). - pub boundary: Vec<[f64; 2]>, - pub solid: bool, -} - -/// Construction spec for a linear `DIMENSION`: the two measured points and the -/// dimension-line definition point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionSpec { - pub first_point: [f64; 3], - pub second_point: [f64; 3], - pub definition_point: [f64; 3], -} - -/// Construction spec for a radial/diameter `DIMENSION`: the circle center and a -/// point on the circle (radius) or a chord point (diameter). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionRadialSpec { - /// Center of the arc/circle (radius) or first chord point (diameter). - pub center: [f64; 3], - /// A point on the arc/circle (radius) or the far chord point (diameter). - pub point: [f64; 3], -} - -/// Construction spec for a 3-point angular `DIMENSION`: vertex + one point on -/// each leg, plus the dimension-arc location. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionAngularSpec { - pub vertex: [f64; 3], - pub first_point: [f64; 3], - pub second_point: [f64; 3], - pub arc_location: [f64; 3], -} - -/// Construction spec for an `ATTDEF` (attribute definition): the tag, prompt, -/// and default value for an in-block attribute, at an insertion point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AttributeDefinitionSpec { - pub tag: String, - pub prompt: String, - pub default_value: String, - pub insertion_point: [f64; 3], - pub height: f64, - pub rotation: f64, -} - -/// Construction spec for a `TABLE`: an insertion point plus a grid of cell -/// strings (`data[row][column]`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TableSpec { - pub insertion_point: [f64; 3], - /// Grid of cell text values, rows × columns. - pub data: Vec>, -} - -/// Construction spec for a `RASTER_IMAGE`: an image file placed at -/// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and -/// pixel `size` (width, height). The host auto-registers the ImageDefinition. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RasterImageSpec { - pub file_path: String, - pub insertion_point: [f64; 3], - pub u_vector: [f64; 3], - pub v_vector: [f64; 3], - /// Pixel size (width, height). - pub size: [f64; 2], -} - -/// A rigid similarity (rotation + translation + optional uniform scale/reflection) -/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` -/// (non-serde). Columns are the new basis axes; `origin` is the translation. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct PlacementSpec { - pub x_axis: [f64; 3], - pub y_axis: [f64; 3], - pub z_axis: [f64; 3], - pub origin: [f64; 3], -} - -impl PlacementSpec { - /// Identity placement (no transform). - pub const IDENTITY: PlacementSpec = PlacementSpec { - x_axis: [1.0, 0.0, 0.0], - y_axis: [0.0, 1.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0, 0.0, 0.0], - }; - - /// Pure translation to `origin` (axes unchanged). - pub fn at(origin: [f64; 3]) -> Self { - Self { - origin, - ..Self::IDENTITY - } - } -} - -/// The item-count cap for a single bulk op envelope: bounds -/// host-side execution time and payload. Over-cap → `ApiError::Validation`. -pub const BULK_ITEM_CAP: usize = 100_000; - -/// Convenience accessors used by the facade/executor. -impl crate::gen::Operation { - /// The op name used in `ApiError::Validation { op, .. }`. - pub fn op_name(&self) -> &'static str { - use crate::gen::Operation::*; - match self { - CreateSolid(_) => "CreateSolid", - CreateCurve(_) => "CreateCurve", - Extrude { .. } => "Extrude", - Revolve { .. } => "Revolve", - Transform { .. } => "Transform", - SolidBoolean { .. } => "SolidBoolean", - AddVertex { .. } => "AddVertex", - Delete { .. } => "Delete", - CreateMany(_) => "CreateMany", - TransformMany { .. } => "TransformMany", - DeleteMany(_) => "DeleteMany", - CreateInsert(_) => "CreateInsert", - CreateViewport(_) => "CreateViewport", - CreateText(_) => "CreateText", - CreateMText(_) => "CreateMText", - SetTextContent { .. } => "SetTextContent", - CreateHatch(_) => "CreateHatch", - CreateDimensionLinear(_) => "CreateDimensionLinear", - SetAttribute { .. } => "SetAttribute", - SetViewportView { .. } => "SetViewportView", - CreateRasterImage(_) => "CreateRasterImage", - Loft { .. } => "Loft", - CreateDimensionRadius(_) => "CreateDimensionRadius", - CreateDimensionDiameter(_) => "CreateDimensionDiameter", - CreateDimensionAngular(_) => "CreateDimensionAngular", - CreateAttributeDefinition(_) => "CreateAttributeDefinition", - CreateTable(_) => "CreateTable", - CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", - SetXData { .. } => "SetXData", - SetXDataMany(_) => "SetXDataMany", - CreateXRecord(_) => "CreateXRecord", - SetXRecord { .. } => "SetXRecord", - CreateLayer(_) => "CreateLayer", - UpdateLayer { .. } => "UpdateLayer", - DeleteLayer { .. } => "DeleteLayer", - SetEntityLayer { .. } => "SetEntityLayer", - } - } -} diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs deleted file mode 100644 index e53d2b650..000000000 --- a/crates/ocs_doc_api/src/query.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Read queries — typed, transport-agnostic reads of entity state. -//! The `Query` enum is **hand-maintained** (append-only) in `src/gen/query_gen.rs` -//! — NOT derived from the spec by build.rs (the spec's `query` names must map to -//! a variant here, asserted by a test). This module re-exports it and holds the -//! hand-written result DTOs. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::revision::GeometryRevision; - -pub use crate::gen::Query; - -/// Axis-aligned bounding box (plain-data mirror of the kernel's non-serde `Aabb`). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Aabb { - pub min: [f64; 3], - pub max: [f64; 3], -} - -impl Aabb { - #[cfg(feature = "host")] - pub fn overlaps(&self, other: &Aabb) -> bool { - cadkernel::brep::Aabb { - min: self.min, - max: self.max, - } - .overlaps(&cadkernel::brep::Aabb { - min: other.min, - max: other.max, - }) - } -} - -/// A generic, untyped view of an entity returned by [`Query::GetEntity`]. -/// Family-specific data lives in the typed construction specs; this carries -/// identity + kind + a coarse bounding box so any first-level entity is readable. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EntityView { - pub id: ObjectId, - /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line"). - pub kind: String, - pub bounds: Option, - /// The normalized (uppercase) layer name the entity is on. - pub layer: String, -} - -/// The result of a single [`Query`] (one per query in a `Queries` batch). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum QueryResult { - Entity(EntityView), - Bounds(Aabb), - Centroid([f64; 3]), - Volume(f64), - /// Whether two entities' bounds overlap (two-phase conditional modelling). - Intersects(bool), - Revision(GeometryRevision), - /// The text content of a Text/MText annotation. - TextContent(String), - /// A hatch's boundary loops (outer + islands), each a closed polyline. - HatchBoundary(Vec>), - /// A dimension's measured value (distance for linear/radius, degrees for angular). - DimensionMeasurement(f64), - /// An insert's attributes as (tag, value) pairs. - Attributes(Vec<(String, String)>), - /// The entities inside a block definition (read-only traversal). - BlockEntities(Vec), - /// A viewport's view: target point (WCS) + zoom height. - ViewportView { - target: [f64; 3], - height: f64, - }, - /// An XDATA record for a single application (`None` if no such record). - XData(Option), - /// An XRECORD object payload. - XRecord(crate::ops::XRecordSpec), - /// All layers in the document. - Layers(Vec), - /// The layer name of an entity. - EntityLayer(String), - /// A filtered list of entity views. - Entities(Vec), - /// The WCS location of a Point entity. - PointPosition([f64; 3]), - /// The start and end WCS points of a Line entity. - LineGeometry(([f64; 3], [f64; 3])), -} - -/// Convenience: the query name for diagnostics. -impl crate::gen::Query { - pub fn query_name(&self) -> &'static str { - use crate::gen::Query::*; - match self { - GetEntity { .. } => "GetEntity", - GetBounds { .. } => "GetBounds", - GetCentroid { .. } => "GetCentroid", - GetVolume { .. } => "GetVolume", - GetIntersects { .. } => "GetIntersects", - GetGeometryRevision => "GetGeometryRevision", - GetTextContent { .. } => "GetTextContent", - GetHatchBoundary { .. } => "GetHatchBoundary", - GetDimensionMeasurement { .. } => "GetDimensionMeasurement", - GetAttributes { .. } => "GetAttributes", - GetBlockEntities { .. } => "GetBlockEntities", - GetViewportView { .. } => "GetViewportView", - GetXData { .. } => "GetXData", - GetXRecord { .. } => "GetXRecord", - ListLayers => "ListLayers", - GetEntityLayer { .. } => "GetEntityLayer", - EnumerateEntities { .. } => "EnumerateEntities", - GetPointPosition { .. } => "GetPointPosition", - GetLineGeometry { .. } => "GetLineGeometry", - } - } -} diff --git a/crates/ocs_doc_api/src/revision.rs b/crates/ocs_doc_api/src/revision.rs deleted file mode 100644 index f0759afe9..000000000 --- a/crates/ocs_doc_api/src/revision.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Geometry revision counter (mirrors the host scene's `geometry_epoch`). - -use serde::{Deserialize, Serialize}; - -/// Monotonic revision of a document's geometry. Bumped exactly once per applied -/// write op (`push_undo` + `finalize_op`). Pure queries return the current -/// revision without bumping it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct GeometryRevision(pub u64); - -impl GeometryRevision { - pub const fn as_u64(self) -> u64 { - self.0 - } -} diff --git a/crates/ocs_doc_api/src/transport/inproc.rs b/crates/ocs_doc_api/src/transport/inproc.rs deleted file mode 100644 index 8d9353024..000000000 --- a/crates/ocs_doc_api/src/transport/inproc.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! In-process transport: drives a `DocApiBackend` directly, inline. -//! Used by in-tree/built-in plugins and host tests. Thread-safe via a mutex so -//! many handles across many threads share it (calls are serialized). - -use std::sync::Mutex; - -use crate::backend::DocApiBackend; -use crate::envelope::{DocApiEnvelope, EnvelopeBody, Receipt}; -use crate::error::ApiResult; -use crate::executor; -use crate::transport::Transport; - -/// In-process transport: holds the backend behind a mutex (reentrant `&self`). -pub struct InProcess { - backend: Mutex, -} - -impl InProcess { - pub fn new(backend: B) -> Self { - Self { - backend: Mutex::new(backend), - } - } - /// Borrow the backend immutably (test/introspection helper). - pub fn backend(&self) -> std::sync::MutexGuard<'_, B> { - self.backend.lock().expect("in-process backend poisoned") - } -} - -impl Transport for InProcess { - fn apply(&self, req: DocApiEnvelope) -> ApiResult { - req.validate_version()?; - let mut backend = self - .backend - .lock() - .map_err(|_| crate::error::ApiError::Transport("in-process backend poisoned".into()))?; - match req.body { - EnvelopeBody::Op(op) => executor::apply_op(&mut *backend, op), - EnvelopeBody::Queries(queries) => executor::apply_queries(&mut *backend, queries), - } - } -} diff --git a/crates/ocs_doc_api/src/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs deleted file mode 100644 index f9b4b1d3e..000000000 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! IPC transport: serializes `DocApiEnvelope` into the -//! append-only `PluginRequest::DocApiRequest { tab_id, bytes }` variant and ships -//! it through the plugin's `PluginRequestSender`. The host routes that variant to -//! the same crate executor (`ocs_doc_api::executor`) — one implementation. - -use std::sync::Arc; - -use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender}; -use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::facade::DocApi; -use crate::transport::Transport; - -/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; -/// the underlying `PluginRequestSender` already serializes + correlates (V4). -pub struct OcsPluginApiIpc { - sender: Arc, - tab_id: u64, -} - -impl OcsPluginApiIpc { - pub fn new(sender: Arc, tab_id: u64) -> Self { - Self { sender, tab_id } - } - - fn transport_err(e: PluginRequestError) -> ApiError { - ApiError::Transport(e.0) - } -} - -/// Build a typed [`DocApi`] from any host that can provide a -/// [`PluginRequestSender`] (out-of-process / V4 worker plugins). -/// -/// Returns `None` for hosts that do not expose a request sender (e.g. in-process -/// hosts). In-process code should use [`crate::DocApi::in_process`] with a -/// concrete [`crate::backend::DocApiBackend`]. -pub fn doc_api_for_host(host: &dyn HostApi) -> Option { - let sender = host.plugin_request_sender()?; - let tab_id = host.tab_id(); - Some(DocApi::connect( - Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)), - tab_id, - )) -} - -impl Transport for OcsPluginApiIpc { - fn apply(&self, req: DocApiEnvelope) -> ApiResult { - let bytes = bincode::serialize(&req) - .map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?; - let resp = self - .sender - .request(PluginRequest::DocApiRequest { - tab_id: self.tab_id, - bytes, - }) - .map_err(Self::transport_err)?; - match resp { - PluginResponse::DocApiResponse { bytes } => { - // The host serializes `ApiResult` so op/query errors - // (validation, geometry, unknown id) surface as the SAME - // structured `ApiError` the in-process executor produced. - let result: ApiResult = bincode::deserialize(&bytes) - .map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?; - result - } - PluginResponse::Error(msg) => Err(ApiError::Transport(msg)), - other => Err(ApiError::Transport(format!( - "unexpected DocApi response variant: {other:?}" - ))), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::facade::HasId; - use crate::ObjectId; - use ocs_plugin_api::host::{ - CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi, - }; - use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - use std::any::Any; - use std::sync::{Arc, Mutex}; - - #[derive(Clone)] - struct MockSender { - captured: Arc>>, - } - - impl PluginRequestSender for MockSender { - fn request(&self, req: PluginRequest) -> Result { - let tab_id = match &req { - PluginRequest::DocApiRequest { tab_id, .. } => *tab_id, - _ => 0, - }; - self.captured.lock().unwrap().push((tab_id, req)); - Ok(PluginResponse::DocApiResponse { - bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt { - outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))), - query_results: vec![], - new_revision: crate::revision::GeometryRevision(1), - })) - .unwrap(), - }) - } - } - - struct MockHost { - sender: Option, - } - - impl HostApi for MockHost { - fn tab_index(&self) -> usize { - 0 - } - fn document(&self) -> &CadDocument { - unimplemented!() - } - fn document_mut(&mut self) -> &mut CadDocument { - unimplemented!() - } - fn add_entity(&mut self, _entity: EntityType) -> Handle { - unimplemented!() - } - fn bump_geometry(&mut self) {} - fn push_undo(&mut self, _label: &str) {} - fn set_dirty(&mut self) {} - fn push_info(&mut self, _msg: &str) {} - fn push_output(&mut self, _msg: &str) {} - fn push_error(&mut self, _msg: &str) {} - fn start_interactive(&mut self, _command: Box) {} - fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> { - None - } - fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool { - false - } - fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { - false - } - fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { - None - } - fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { - None - } - fn ensure_plugin_state_any( - &mut self, - _plugin_id: &'static str, - _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync) { - unimplemented!() - } - fn document_reader(&self) -> Box { - unimplemented!() - } - fn plugin_request_sender(&self) -> Option> { - self.sender.as_ref().map(|s| Box::new(s.clone()) as Box) - } - } - - #[test] - fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() { - let captured = Arc::new(Mutex::new(Vec::new())); - let host = MockHost { - sender: Some(MockSender { - captured: Arc::clone(&captured), - }), - }; - let doc_api = doc_api_for_host(&host).expect("host exposes a sender"); - let doc = doc_api.document(host.tab_id()); - let id = doc - .solids() - .create_cuboid([0.0; 3], [1.0; 3]) - .expect("mock returns a NewId receipt"); - assert_eq!(id.id(), ObjectId::from_u64(7)); - - let calls = captured.lock().unwrap(); - assert_eq!(calls.len(), 1); - let (tab_id, ref req) = &calls[0]; - assert_eq!(*tab_id, host.tab_id()); - match req { - PluginRequest::DocApiRequest { tab_id, bytes } => { - assert_eq!(*tab_id, host.tab_id()); - assert!(!bytes.is_empty()); - } - other => panic!("expected DocApiRequest, got {other:?}"), - } - } - - #[test] - fn doc_api_for_host_returns_none_without_sender() { - let host = MockHost { sender: None }; - assert!(doc_api_for_host(&host).is_none()); - } -} diff --git a/crates/ocs_doc_api/src/transport/mod.rs b/crates/ocs_doc_api/src/transport/mod.rs deleted file mode 100644 index 3a17f92e4..000000000 --- a/crates/ocs_doc_api/src/transport/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! The transport boundary — the ONLY plugin↔host boundary. -//! -//! Portability contract (SPSC / flume / shared-memory safe): a transport impl -//! must (a) accept `&self` calls from many threads and serialize sends internally, -//! (b) demultiplex responses to the right caller by a correlation id, (c) -//! block-with-timeout, (d) expose liveness. This matches the existing V4 channel -//! shape, so swapping socket → shm changes only the impl, never the facade. - -#[cfg(feature = "host")] -pub mod inproc; -#[cfg(feature = "ipc")] -pub mod ipc; - -#[cfg(feature = "host")] -pub use inproc::InProcess; -#[cfg(feature = "ipc")] -pub use ipc::OcsPluginApiIpc; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::ApiResult; - -/// The only plugin↔host boundary. Object-centric: one method per unit of work — -/// a single write op, or a query batch. `&self` + `Send + Sync` so many entity -/// handles across many threads share one transport concurrently. -pub trait Transport: Send + Sync { - /// Apply `req` (ONE write op, or a query batch) and return its `Receipt`. - /// A write request carries exactly one `Operation`; a read request carries - /// one-or-more `Query` items (read-only batching is safe). Blocks up to an - /// internal timeout. Reentrant: safe to call concurrently from many threads. - fn apply(&self, req: DocApiEnvelope) -> ApiResult; - - /// Cheap host-liveness probe (shm has no socket-disconnect to detect a dead peer). - fn alive(&self) -> bool { - true - } -} diff --git a/crates/ocs_doc_api/src/validation.rs b/crates/ocs_doc_api/src/validation.rs deleted file mode 100644 index dc9ca9909..000000000 --- a/crates/ocs_doc_api/src/validation.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; - -fn finite(values: &[f64]) -> bool { - values.iter().all(|v| v.is_finite()) -} -fn positive(value: f64) -> bool { - value.is_finite() && value > 0.0 -} -fn direction(value: &[f64; 3]) -> bool { - finite(value) && value.iter().any(|v| *v != 0.0) -} -fn check(valid: bool, op: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation( - op, - "invalid or nonfinite geometry input", - )) - } -} - -fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation(op, reason)) - } -} - -fn valid_application_name(name: &str) -> bool { - !name.trim().is_empty() && !name.contains('\0') -} - -fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool { - valid_application_name(app) - && record.is_none_or(|record| { - record.application_name == app - && record.values.iter().all(|value| match value { - XDataValue::String(value) - | XDataValue::ControlString(value) - | XDataValue::LayerName(value) => !value.contains('\0'), - XDataValue::Point3D(value) - | XDataValue::Position3D(value) - | XDataValue::Displacement3D(value) - | XDataValue::Direction3D(value) => finite(value), - XDataValue::Real(value) - | XDataValue::Distance(value) - | XDataValue::ScaleFactor(value) => value.is_finite(), - XDataValue::BinaryData(_) - | XDataValue::Handle(_) - | XDataValue::Integer16(_) - | XDataValue::Integer32(_) => true, - }) - }) -} - -fn valid_xrecord(spec: &XRecordSpec) -> bool { - !spec.name.trim().is_empty() - && !spec.name.contains('\0') - && spec.entries.len() <= BULK_ITEM_CAP - && spec.entries.iter().all(|entry| { - let code = entry.code; - match &entry.value { - XRecordValue::String(value) => { - matches!( - code, - 0..=4 - | 6..=9 - | 100..=102 - | 300..=309 - | 410..=419 - | 430..=439 - | 470..=479 - | 999 - | 1000..=1003 - ) && !value.contains('\0') - && value.encode_utf16().count() <= u16::MAX as usize - } - XRecordValue::Point3D(value) => { - matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069) - && finite(value) - } - XRecordValue::Double(value) => { - matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042) - && value.is_finite() - } - XRecordValue::Int16(_) => { - matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070) - } - XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071), - XRecordValue::Int64(_) => matches!(code, 150..=169), - XRecordValue::Byte(_) => matches!(code, 280..=289), - XRecordValue::Bool(_) => matches!(code, 290..=299), - XRecordValue::Handle(_) => { - code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005) - } - XRecordValue::Chunk(value) => { - matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize - } - } - }) -} - -pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { - use Curve2Spec::*; - let valid = match spec { - Line { start, end, .. } => finite(start) && finite(end) && start != end, - Circle { centre, radius, .. } => finite(centre) && positive(*radius), - Point { position, .. } => finite(position), - Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]), - Polyline { points, closed, .. } => { - points.len() >= if *closed { 3 } else { 2 } - && points.len() <= BULK_ITEM_CAP - && points.iter().all(|p| finite(p) && p[2] == points[0][2]) - } - Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - finite(centre) - && direction(major_axis) - && positive(*ratio) - && *ratio <= 1.0 - && finite(&[*start, *end]) - } - Spline { - degree, - control_points, - knots, - weights, - .. - } => { - *degree > 0 - && control_points.len() <= BULK_ITEM_CAP - && knots.len() <= BULK_ITEM_CAP - && weights.len() <= BULK_ITEM_CAP - && cadkernel::space::NurbsCurve3::new_strict( - *degree as usize, - control_points.clone(), - knots.clone(), - if weights.is_empty() { - vec![1.0; control_points.len()] - } else { - weights.clone() - }, - ) - .is_some() - } - Ray { - origin, - direction: d, - .. - } - | XLine { - origin, - direction: d, - .. - } => finite(origin) && direction(d), - }; - check(valid, "CreateCurve") -} - -pub(crate) fn operation(op: &Operation) -> ApiResult<()> { - use Operation::*; - let valid = match op { - CreateCurve(spec) => return curve(spec), - Extrude { direction: d, .. } => direction(d), - Revolve { - axis: (pivot, d), - angle, - .. - } => finite(pivot) && direction(d) && angle.is_finite() && *angle != 0.0, - AddVertex { point, .. } => finite(point), - CreateInsert(s) => { - finite(&s.insert_point) - && s.scale.is_finite() - && s.scale != 0.0 - && s.rotation.is_finite() - } - CreateViewport(s) => { - finite(&s.center) - && finite(&s.view_target) - && positive(s.width) - && positive(s.height) - && positive(s.view_height) - } - CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), - CreateMText(s) => finite(&s.insertion_point) && positive(s.height), - SetXData { - id, - application_name, - record, - } => { - return check_payload( - *id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()), - "SetXData", - "invalid XDATA record", - ) - } - SetXDataMany(records) => { - return check_payload( - records.iter().all(|(id, app, record)| { - *id != ObjectId::NULL && valid_xdata_record(app, record.as_ref()) - }), - "SetXDataMany", - "invalid XDATA record", - ) - } - CreateXRecord(spec) => { - return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload") - } - SetXRecord { id, spec } => { - return check_payload( - *id != ObjectId::NULL && valid_xrecord(spec), - "SetXRecord", - "invalid XRecord payload", - ) - } - CreateHatch(s) => { - (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) - } - CreateDimensionLinear(s) => { - finite(&s.first_point) && finite(&s.second_point) && finite(&s.definition_point) - } - CreateDimensionRadius(s) | CreateDimensionDiameter(s) => { - finite(&s.center) && finite(&s.point) - } - CreateDimensionAngular(s) | CreateDimensionAngular2Ln(s) => { - finite(&s.vertex) - && finite(&s.first_point) - && finite(&s.second_point) - && finite(&s.arc_location) - } - CreateAttributeDefinition(s) => { - finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite() - } - SetViewportView { - view_target, - view_height, - .. - } => finite(view_target) && positive(*view_height), - CreateRasterImage(s) => { - finite(&s.insertion_point) - && direction(&s.u_vector) - && direction(&s.v_vector) - && s.size.iter().all(|v| positive(*v)) - } - CreateTable(s) => { - finite(&s.insertion_point) - && !s.data.is_empty() - && !s.data[0].is_empty() - && s.data - .len() - .checked_mul(s.data[0].len()) - .is_some_and(|n| n <= BULK_ITEM_CAP) - && s.data.iter().all(|r| r.len() == s.data[0].len()) - } - _ => true, - }; - check(valid, op.op_name()) -} - -pub(crate) fn solid(spec: &SolidPrimitive) -> ApiResult<()> { - use SolidPrimitive::*; - let valid = match spec { - Cuboid { origin, size } | Wedge { origin, size } => { - finite(origin) && size.iter().all(|v| positive(*v)) - } - Sphere { centre, radius } => finite(centre) && positive(*radius), - Cylinder { - base, - radius, - height, - } - | Cone { - base, - radius, - height, - } => finite(base) && positive(*radius) && positive(*height), - Torus { - centre, - major_radius, - minor_radius, - } => finite(centre) && positive(*major_radius) && positive(*minor_radius), - }; - check(valid, "CreateSolid") -} diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs deleted file mode 100644 index d9059bbde..000000000 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! Unit tests over an in-memory mock `DocApiBackend`: no host. -//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup -//! compensation, bulk all-or-nothing, revision bumps, read-only query batching, -//! and stale-handle `UnknownId` semantics. - -use std::collections::HashMap; -use std::sync::Arc; - -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView, - GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec, - XDataRecord, XDataValue, -}; - -/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and -/// the example mock — all three must answer curve bounds identically). -fn curve_bounds(spec: &Curve2Spec) -> Aabb { - match spec { - Curve2Spec::Line { start, end, .. } => Aabb { - min: [ - start[0].min(end[0]), - start[1].min(end[1]), - start[2].min(end[2]), - ], - max: [ - start[0].max(end[0]), - start[1].max(end[1]), - start[2].max(end[2]), - ], - }, - Curve2Spec::Circle { centre, radius, .. } - | Curve2Spec::Arc { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Point { position, .. } => Aabb { - min: *position, - max: *position, - }, - Curve2Spec::Polyline { points, .. } - | Curve2Spec::Spline { - control_points: points, - .. - } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Ellipse { - centre, major_axis, .. - } => { - let r = (major_axis[0] * major_axis[0] - + major_axis[1] * major_axis[1] - + major_axis[2] * major_axis[2]) - .sqrt(); - Aabb { - min: [centre[0] - r, centre[1] - r, centre[2] - r], - max: [centre[0] + r, centre[1] + r, centre[2] + r], - } - } - Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { - min: *origin, - max: *origin, - }, - } -} - -/// In-memory mock backend: HashMap + a tiny entity table. -/// Tracks undo steps + revision bumps to assert per-op semantics. -#[derive(Clone)] -struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, - /// Stored layer table. - layers: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored 2D curve specs so `bounds()` works on curve entities (aligned with - /// the example mock — both mocks must answer curve bounds the same way). - curves: HashMap, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - entity_layers: HashMap::new(), - layers: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - curves: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - }; - backend - .layers - .insert("0".to_string(), ocs_doc_api::LayerInfo::new("0")); - backend - } -} - -impl MockBackend { - fn alloc(&mut self, kind: &str) -> ObjectId { - self.next_id += 1; - let id = ObjectId::from_u64(self.next_id); - self.kinds.insert(id, kind.to_string()); - id - } - fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { - self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) - } - fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - Ok(id) - } -} - -impl DocApiBackend for MockBackend { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - self.bodies.get(&id).cloned().ok_or(ApiError::UnknownId(id)) - } - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let id = self.alloc("Solid3D"); - self.bodies.insert(id, body.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - if self.bodies.insert(id, body.clone()).is_none() { - return Err(ApiError::UnknownId(id)); - } - Ok(()) - } - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.add_curve_only(spec)?; - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - if spec.block_name.is_empty() { - return Err(ApiError::validation("CreateInsert", "empty block name")); - } - let id = self.alloc("Insert"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - let id = self.alloc("Viewport"); - self.viewport_views - .insert(id, (spec.view_target, spec.view_height)); - Ok(id) - } - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - let id = self.alloc("Text"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - let id = self.alloc("MText"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { - e.insert(value.to_string()); - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn text_content(&self, id: ObjectId) -> ApiResult { - self.text_values - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - self.hatch_boundaries - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) - + (spec.first_point[1] - spec.second_point[1]).powi(2) - + (spec.first_point[2] - spec.second_point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.center[0] - spec.point[0]).powi(2) - + (spec.center[1] - spec.point[1]).powi(2) - + (spec.center[2] - spec.point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.add_dimension_radius(spec)?; - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_angular( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - self.dimension_measurements - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - if spec.tag.is_empty() { - return Err(ApiError::validation( - "CreateAttributeDefinition", - "empty tag", - )); - } - Ok(self.alloc("AttributeDefinition")) - } - fn add_dimension_angular2ln( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees - Ok(id) - } - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - let id = self.alloc("Table"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let attrs = self.attributes_store.entry(id).or_default(); - if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { - a.1 = value.to_string(); - } else { - attrs.push((tag.to_string(), value.to_string())); - } - Ok(()) - } - fn attributes(&self, id: ObjectId) -> ApiResult> { - self.attributes_store - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn block_entities(&self, _block_name: &str) -> ApiResult> { - Ok(Vec::new()) - } - fn add_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - let id = self.alloc("RasterImage"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - if sections.len() < 2 { - return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); - } - // Mock: store a unit cuboid as the loft result. - let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; - self.store_solid(&body) - } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - self.viewport_views.insert(id, (view_target, view_height)); - Ok(()) - } - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - self.viewport_views - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self - .entity_layers - .values() - .any(|l| normalize_layer_name(l) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let bounds = self.bodies.get(&id).and_then(|b| { - cadkernel::brep::body_bounds(b).map(|bb| Aabb { - min: bb.min, - max: bb.max, - }) - }); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - Ok(EntityView { - id, - kind, - layer, - bounds, - }) - } - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if let Some(body) = self.bodies.get(&id).cloned() { - let place = cadkernel::brep::Placement { - x_axis: placement.x_axis, - y_axis: placement.y_axis, - z_axis: placement.z_axis, - origin: placement.origin, - }; - let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.bodies.insert(id, moved); - } - Ok(()) - } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - Err(ApiError::Unsupported("profiles not mocked".into())) - } - fn bounds(&mut self, id: ObjectId) -> ApiResult { - // Solids via the kernel body; 2D curves from their stored spec (aligned - // with the example mock — both mocks answer curve bounds identically). - if let Some(spec) = self.curves.get(&id) { - return Ok(curve_bounds(spec)); - } - let body = self.body(id)?; - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) - } - fn volume(&mut self, id: ObjectId) -> ApiResult { - let body = self.body(id)?; - Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) - } - fn entity_exists(&self, id: ObjectId) -> bool { - self.kinds.contains_key(&id) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.contains_key(&id) - } - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.revision) - } - fn push_undo(&mut self, _label: &str) {} - fn finalize_op(&mut self) { - self.undo_steps += 1; - self.revision += 1; - } -} - -fn api() -> (DocApi, Arc>) { - let tp = Arc::new(ocs_doc_api::transport::InProcess::new( - MockBackend::default(), - )); - (DocApi::connect(tp.clone(), 0), tp) -} - -#[test] -fn cuboid_intersect_cuboid_atomic_ops_and_revision() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let mut grp = ocs_doc_api::OpGroup::new(); - - let rev0 = doc.revision().unwrap(); - // Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()). - let block = grp - .track( - doc.solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]), - ) - .unwrap(); - let other = grp - .track( - doc.solids() - .create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]), - ) - .unwrap(); - assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 2); - - let lens = match block.intersect(&other) { - Ok(l) => { - grp.commit(); - l - } - Err(e) => panic!("intersect failed: {e}"), - }; - assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 3); - - let res = doc - .query_batch(|q| { - q.bounds(&lens); - q.volume(&lens); - }) - .unwrap(); - let bb = res.bounds(0).unwrap(); - let vol = res.volume(1).unwrap(); - // Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125. - assert!( - (bb.min[0] - 5.0).abs() < 1e-6 && (bb.max[0] - 10.0).abs() < 1e-6, - "bounds {bb:?}" - ); - assert!(vol > 0.0, "intersection volume must be positive"); -} - -#[test] -fn boolean_erase_sources_removes_second_solid() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - let b = doc - .solids() - .create_cuboid([5.0, 5.0, 5.0], [10.0; 3]) - .unwrap(); - let result = a.union(&b).unwrap(); - let backend = tp.backend(); - assert!(backend.entity_exists(a.id())); - assert!(!backend.entity_exists(b.id())); - assert_eq!(result.id(), a.id()); -} - -#[test] -fn stale_handle_get_returns_error() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let ghost = ObjectId::from_u64(999_999); - assert!(doc.entities().get(ghost).is_err()); -} - -#[test] -fn opgroup_compensate_deletes_created() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let mut grp = ocs_doc_api::OpGroup::new(); - let a = grp - .track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])) - .unwrap(); - let b = grp - .track(doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)) - .unwrap(); - grp.compensate(&doc).unwrap(); - let backend = tp.backend(); - assert!(!backend.entity_exists(a.id())); - assert!(!backend.entity_exists(b.id())); -} - -#[test] -fn bulk_create_many_one_undo_step() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - let coords: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64, 0.0, 0.0]).collect(); - let pts = doc.curves().create_points(&coords).unwrap(); - assert_eq!(pts.len(), 1000); - let backend = tp.backend(); - assert_eq!(backend.revision, rev0 + 1); - assert_eq!(backend.undo_steps, 1); -} - -#[test] -fn transform_many_with_stale_id_fails_and_applies_nothing() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - let rev_before = doc.revision().unwrap().as_u64(); - let stale = ObjectId::from_u64(424_242); - let err = doc - .entities() - .transform_many(&[a.id(), stale], PlacementSpec::at([1.0, 0.0, 0.0])) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - let backend = tp.backend(); - assert_eq!(backend.revision, rev_before); -} - -#[test] -fn assert_revision_guard() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let rev = doc.revision().unwrap(); - doc.assert_revision(rev).unwrap(); - doc.solids() - .create_cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .unwrap(); - assert!(doc.assert_revision(rev).is_err()); -} - -#[test] -fn failed_boolean_leaves_inputs_live_and_no_undo() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - // `b` is stale: boolean fails before push_undo -> no mutation, no undo step. - let stale_solid = { - // Build a Solid handle to a non-existent id via Entity downcast path. - // Here we just call intersect against a deleted id. - let tmp = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0).unwrap(); - tmp.delete().unwrap(); - tmp - }; - let rev_before = doc.revision().unwrap().as_u64(); - assert!(a.intersect(&stale_solid).is_err()); - let backend = tp.backend(); - // `a` is still live; no undo recorded for the failed boolean. - assert!(backend.entity_exists(a.id())); - assert_eq!(backend.revision, rev_before); -} - -#[test] -fn typed_handles_reject_foreign_sessions_and_tabs() { - let first = DocApi::in_process(MockBackend::default(), 10); - let second = DocApi::in_process(MockBackend::default(), 10); - let doc = first.document(10); - let foreign = second - .document(10) - .curves() - .create_circle([0.0; 3], 2.0) - .unwrap(); - assert!(doc.solids().extrude(&foreign, [0.0, 0.0, 2.0]).is_err()); - assert!(doc - .solids() - .loft(&[foreign.clone(), foreign.clone()]) - .is_err()); - assert!(doc.query_batch(|q| q.bounds(&foreign)).is_err()); - let mut group = ocs_doc_api::OpGroup::new(); - group.track(Ok(foreign)).unwrap(); - assert!(group.compensate(&doc).is_err()); - assert!(first.document(11).curves().create_point([0.0; 3]).is_err()); -} - -#[test] -fn doc_api_layer_crud_via_facade_and_entity_assignment() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - doc.create_layer(&info).unwrap(); - - let layers = doc.layers().unwrap(); - assert!(layers.iter().any(|l| l.name == "WALLS")); - - let mut updated = info.clone(); - updated.color = Color::Index(2); - doc.update_layer("walls", &updated).unwrap(); - - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - line.set_layer("Walls").unwrap(); - assert_eq!(line.layer().unwrap(), "WALLS"); - - let err = doc.delete_layer("Walls").unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let other = LayerInfo::new("Other"); - doc.create_layer(&other).unwrap(); - line.set_layer("Other").unwrap(); - doc.delete_layer("Walls").unwrap(); - assert!(!doc.layers().unwrap().iter().any(|l| l.name == "WALLS")); -} - -#[test] -fn set_xdata_many_is_atomic_and_assigns_records() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let circle = doc.curves().create_circle([0.0; 3], 1.0).unwrap(); - - let record1 = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let record2 = XDataRecord { - application_name: "APP2".to_string(), - values: vec![XDataValue::Real(3.14)], - }; - doc.set_xdata_many([ - (line.id(), "APP1", Some(record1.clone())), - (circle.id(), "APP2", Some(record2.clone())), - ]) - .unwrap(); - - let r1 = doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().unwrap(); - assert_eq!(r1.values, vec![XDataValue::Integer32(42)]); - let r2 = doc.entities().get(circle.id()).unwrap().xdata("APP2").unwrap().unwrap(); - assert_eq!(r2.values, vec![XDataValue::Real(3.14)]); -} - -#[test] -fn set_xdata_many_with_unknown_id_fails_atomically() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let stale = ObjectId::from_u64(999_999); - - let record = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let err = doc - .set_xdata_many([ - (line.id(), "APP1", Some(record.clone())), - (stale, "APP2", Some(record)), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::UnknownId(..) | ApiError::Validation { .. })); - assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none()); -} - -#[test] -fn xdata_rejects_a_mismatched_record_name() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let entity = doc.entities().get(line.id()).unwrap(); - let result = entity.set_xdata( - "EXPECTED", - Some(XDataRecord { - application_name: "OTHER".into(), - values: vec![], - }), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(entity.xdata("EXPECTED").unwrap().is_none()); -} - -#[test] -fn create_xrecord_returns_the_typed_handle() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let spec = ocs_doc_api::XRecordSpec { - name: "PLUGIN_DATA".into(), - cloning_flags: ocs_doc_api::XRecordCloningFlags::NotApplicable, - entries: vec![ocs_doc_api::XRecordEntry { - code: 1, - value: ocs_doc_api::XRecordValue::String("value".into()), - }], - }; - let record = doc.entities().create_xrecord(&spec).unwrap(); - assert_eq!(record.payload().unwrap(), spec); -} - -#[test] -fn create_many_applies_per_curve_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - doc.create_layer(&LayerInfo::new("Walls")).unwrap(); - - let ids = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Walls".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap(); - - assert_eq!(ids[0].layer().unwrap(), "WALLS"); - assert_eq!(ids[1].layer().unwrap(), "0"); -} - -#[test] -fn create_many_rejects_missing_layer_before_mutating() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - - let err = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Missing".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let backend = tp.backend(); - assert_eq!(backend.revision, rev0); - assert_eq!(backend.next_id, 0); -} - -#[test] -fn create_line_on_layer_requires_existing_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let err = doc - .curves() - .create_line_on_layer([0.0; 3], [1.0; 3], "Missing") - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); -} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs deleted file mode 100644 index d7fc6c3dd..000000000 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Spec↔wire consistency tests (enforces the codegen contract the headers -//! promise): every `op`/`query` name in `spec/entities.toml` maps to an existing -//! `Operation`/`Query` variant, and the wire enums are append-only relative to a -//! recorded baseline (bincode discriminant stability). - -use std::collections::BTreeSet; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct Spec { - #[serde(default)] - family: Vec, -} -#[derive(Deserialize)] -struct Family { - #[serde(default)] - handle: Option, - #[serde(default)] - constructor: Vec, - #[serde(default)] - method: Vec, -} -#[derive(Deserialize)] -struct MethodRef { - #[serde(default)] - op: Option, - #[serde(default)] - query: Option, -} - -fn spec() -> Spec { - let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spec/entities.toml")) - .expect("read spec/entities.toml"); - toml::from_str(&src).expect("parse spec/entities.toml") -} - -/// The variant names of the `Operation` enum, in declaration order. -fn operation_variants() -> Vec { - use ocs_doc_api::ops::{ - BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive, - ViewportSpec, - }; - use ocs_doc_api::ObjectId; - let _marker: Vec = vec![ - Operation::CreateSolid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: 1.0, - }), - Operation::CreateCurve(Curve2Spec::Point { - position: [0.0; 3], - layer: None, - }), - Operation::Extrude { - profile: ObjectId::from_u64(0), - direction: [0.0; 3], - }, - Operation::Revolve { - profile: ObjectId::from_u64(0), - axis: ([0.0; 3], [0.0; 3]), - angle: 0.0, - }, - Operation::Transform { - id: ObjectId::from_u64(0), - placement: PlacementSpec::IDENTITY, - }, - Operation::SolidBoolean { - op: BoolOp::Union, - a: ObjectId::from_u64(0), - b: ObjectId::from_u64(0), - erase_sources: true, - }, - Operation::AddVertex { - id: ObjectId::from_u64(0), - at: 0, - point: [0.0; 3], - }, - Operation::Delete { - id: ObjectId::from_u64(0), - }, - Operation::CreateMany(vec![]), - Operation::TransformMany { - ids: vec![], - placement: PlacementSpec::IDENTITY, - }, - Operation::DeleteMany(vec![]), - Operation::CreateInsert(InsertSpec { - block_name: String::new(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - }), - Operation::CreateViewport(ViewportSpec { - center: [0.0; 3], - width: 0.0, - height: 0.0, - view_target: [0.0; 3], - view_height: 0.0, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateMText(ocs_doc_api::ops::MTextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - }), - Operation::SetTextContent { - id: ObjectId::from_u64(0), - value: String::new(), - }, - Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: vec![], - solid: true, - }), - Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { - first_point: [0.0; 3], - second_point: [0.0; 3], - definition_point: [0.0; 3], - }), - Operation::SetAttribute { - id: ObjectId::from_u64(0), - tag: String::new(), - value: String::new(), - }, - Operation::SetViewportView { - id: ObjectId::from_u64(0), - view_target: [0.0; 3], - view_height: 0.0, - }, - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: String::new(), - insertion_point: [0.0; 3], - u_vector: [0.0; 3], - v_vector: [0.0; 3], - size: [0.0; 2], - }), - Operation::Loft { profiles: vec![] }, - Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec { - tag: String::new(), - prompt: String::new(), - default_value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [0.0; 3], - data: vec![], - }), - Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::SetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - record: None, - }, - Operation::SetXDataMany(vec![]), - Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }), - Operation::SetXRecord { - id: ObjectId::from_u64(0), - spec: ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }, - }, - Operation::CreateLayer(LayerInfo::new("LAYER")), - Operation::UpdateLayer { - name: String::new(), - info: LayerInfo::new("LAYER"), - }, - Operation::DeleteLayer { - name: String::new(), - }, - Operation::SetEntityLayer { - id: ObjectId::from_u64(0), - layer: String::new(), - }, - ]; - _marker - .iter() - .enumerate() - .map(|(index, op)| { - let wire = bincode::serialize(op).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", op); - dbg.split(['(', ' ', '{']) - .next() - .unwrap_or("") - .to_string() - }) - .collect() -} - -/// The variant names of the `Query` enum, in declaration order. -fn query_variants() -> Vec { - use ocs_doc_api::query::Query; - use ocs_doc_api::ObjectId; - let marker: Vec = vec![ - Query::GetEntity { - id: ObjectId::from_u64(0), - }, - Query::GetBounds { - id: ObjectId::from_u64(0), - }, - Query::GetCentroid { - id: ObjectId::from_u64(0), - }, - Query::GetVolume { - id: ObjectId::from_u64(0), - }, - Query::GetIntersects { - a: ObjectId::from_u64(0), - b: ObjectId::from_u64(0), - }, - Query::GetGeometryRevision, - Query::GetTextContent { - id: ObjectId::from_u64(0), - }, - Query::GetHatchBoundary { - id: ObjectId::from_u64(0), - }, - Query::GetDimensionMeasurement { - id: ObjectId::from_u64(0), - }, - Query::GetAttributes { - id: ObjectId::from_u64(0), - }, - Query::GetBlockEntities { - block_name: String::new(), - }, - Query::GetViewportView { - id: ObjectId::from_u64(0), - }, - Query::GetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - }, - Query::GetXRecord { - id: ObjectId::from_u64(0), - }, - Query::ListLayers, - Query::GetEntityLayer { - id: ObjectId::from_u64(0), - }, - ]; - marker - .iter() - .enumerate() - .map(|(index, q)| { - let wire = bincode::serialize(q).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", q); - dbg.split(['(', ' ', '{']) - .next() - .unwrap_or("") - .to_string() - }) - .collect() -} - -#[test] -fn every_spec_op_and_query_maps_to_an_enum_variant() { - let spec = spec(); - let op_names: BTreeSet = operation_variants().into_iter().collect(); - let query_names: BTreeSet = query_variants().into_iter().collect(); - - let mut missing = Vec::new(); - for fam in &spec.family { - for m in fam.constructor.iter().chain(fam.method.iter()) { - if let Some(op) = &m.op { - if !op_names.contains(op) { - missing.push(format!( - "Operation::{op} (family {})", - fam.handle.as_deref().unwrap_or("?") - )); - } - } - if let Some(q) = &m.query { - if !query_names.contains(q) { - missing.push(format!( - "Query::{q} (family {})", - fam.handle.as_deref().unwrap_or("?") - )); - } - } - } - } - assert!( - missing.is_empty(), - "spec references ops/queries with no enum variant: {missing:?}" - ); -} - -/// Recorded baseline of the `Operation` variant order (append-only contract). -/// New variants must be APPENDED after these; none may be removed or reordered. -const OPERATION_BASELINE: &[&str] = &[ - "CreateSolid", - "CreateCurve", - "Extrude", - "Revolve", - "Transform", - "SolidBoolean", - "AddVertex", - "Delete", - "CreateMany", - "TransformMany", - "DeleteMany", - "CreateInsert", - "CreateViewport", - "CreateText", - "CreateMText", - "SetTextContent", - "CreateHatch", - "CreateDimensionLinear", - "SetAttribute", - "SetViewportView", - "CreateRasterImage", - "Loft", - "CreateDimensionRadius", - "CreateDimensionDiameter", - "CreateDimensionAngular", - "CreateAttributeDefinition", - "CreateTable", - "CreateDimensionAngular2Ln", - "SetXData", - "SetXDataMany", - "CreateXRecord", - "SetXRecord", - "CreateLayer", - "UpdateLayer", - "DeleteLayer", - "SetEntityLayer", -]; - -/// Recorded baseline of the `Query` variant order. -const QUERY_BASELINE: &[&str] = &[ - "GetEntity", - "GetBounds", - "GetCentroid", - "GetVolume", - "GetIntersects", - "GetGeometryRevision", - "GetTextContent", - "GetHatchBoundary", - "GetDimensionMeasurement", - "GetAttributes", - "GetBlockEntities", - "GetViewportView", - "GetXData", - "GetXRecord", - "ListLayers", - "GetEntityLayer", -]; - -#[test] -fn operation_enum_is_append_only_vs_baseline() { - let current = operation_variants(); - assert!( - current.len() >= OPERATION_BASELINE.len(), - "Operation enum shrank: {current:?} < baseline {OPERATION_BASELINE:?}" - ); - for (i, name) in OPERATION_BASELINE.iter().enumerate() { - assert_eq!( - current[i], *name, - "Operation variant {i} changed (append-only violation): {current:?}" - ); - } -} - -#[test] -fn query_enum_is_append_only_vs_baseline() { - let current = query_variants(); - assert!( - current.len() >= QUERY_BASELINE.len(), - "Query enum shrank: {current:?} < baseline {QUERY_BASELINE:?}" - ); - for (i, name) in QUERY_BASELINE.iter().enumerate() { - assert_eq!( - current[i], *name, - "Query variant {i} changed (append-only violation): {current:?}" - ); - } -} diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index d8ed9a7b0..7a284bc60 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": acadrust.version.to_string(), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 6, + "api_version": 5, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index dfc2acbf4..19130c596 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -552,18 +552,6 @@ pub trait HostApi { let _ = tab_id; None } - - /// DocApi v2 (`ocs_doc_api`): dispatch one bincode-serialized `DocApiEnvelope` - /// (one write op OR a read-only query batch) and return the bincode-serialized - /// `Result`. Opaque bytes: - /// this crate does not depend on `ocs_doc_api`, it only routes the envelope. - /// - /// Added for the DocApi v2 adapter. The default returns `Err` ("not supported"); - /// the in-process host (`HostSession`) overrides it to call the DocApi executor. - fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { - let _ = (tab_id, bytes); - Err("DocApi v2 not supported by this host".to_string()) - } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 585f2cc85..c20cad1b2 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -138,12 +138,6 @@ pub enum PluginRequest { GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. DocumentPath { tab_id: u64 }, - /// DocApi v2 (`ocs_doc_api`): carry one bincode-serialized `DocApiEnvelope` - /// (one write op OR a read-only query batch). Appended at the end to keep - /// existing discriminants stable. The host routes this to the DocApi executor. - /// `serde_bytes` keeps the payload a single length-prefixed bulk copy (the - /// outer frame would otherwise walk it byte-by-byte). - DocApiRequest { tab_id: u64, #[serde(with = "serde_bytes")] bytes: Vec }, } /// Responses the host sends back for `PluginRequest`. @@ -170,10 +164,6 @@ pub enum PluginResponse { TabId(u64), /// V5: filesystem path of the document in the requested tab, if any. DocumentPath(Option), - /// DocApi v2 (`ocs_doc_api`): the bincode-serialized `Result` answering a - /// `PluginRequest::DocApiRequest`. Appended at the end (discriminant stability). - /// `serde_bytes` keeps the payload a single length-prefixed bulk copy. - DocApiResponse { #[serde(with = "serde_bytes")] bytes: Vec }, } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 8394d456e..055880ef0 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,9 +77,5 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), - DocApiRequest { tab_id, bytes } => match host.doc_api_dispatch(tab_id, &bytes) { - Ok(bytes) => PluginResponse::DocApiResponse { bytes }, - Err(msg) => PluginResponse::Error(msg), - }, } } diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index b72648a72..b0e4b3dcd 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -18,15 +18,9 @@ /// plugins and appends `document_reader` / `document_view` at the end of the /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 -/// adds the `BuiltinPlugin::on_load` lifecycle callback. -/// Host ABI major version. Bumped to 6 when the DocApi v2 surface -/// (`PluginRequest::DocApiRequest` / `PluginResponse::DocApiResponse` + -/// `HostApi::doc_api_dispatch`) was added: plugins that call DocApi require an -/// API-6 host and are rejected at handshake by older (API ≤ 5) hosts, instead of -/// failing at runtime with a bincode-decode transport error. The DocApi wire -/// changes are append-only and `doc_api_dispatch` is a defaulted method, so -/// older V2–V5 plugins still load unchanged on an API-6 host. -pub const API_VERSION: u32 = 6; +/// adds the `BuiltinPlugin::on_load` lifecycle callback and +/// `HostApi::document_path`. +pub const API_VERSION: u32 = 5; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs deleted file mode 100644 index 4c27a8fa1..000000000 --- a/src/app/doc_api.rs +++ /dev/null @@ -1,4501 +0,0 @@ -//! Native document API adapter over the existing scene, history and kernel paths. - -use acadrust::entities::Solid3D; -use acadrust::objects::{Dictionary, ObjectType}; -use acadrust::{EntityType, Handle}; -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Color, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - LayerFlags, LayerInfo, LineWeight, ObjectId, PlacementSpec, -}; - -use crate::scene::annotative::root_named_dict_handle; -use crate::scene::convert::acis_export; -use crate::scene::model::solid_model; -use ocs_doc_api::convert; - -use super::plugin_host::HostSession; - -const XRECORD_DICT_NAME: &str = "OCS_XRECORD_DICT"; - -/// Return the stable named-object dictionary used to hold `XRecord` objects. -/// Created on first use under the root named-objects dictionary and reused -/// thereafter. -fn xrecord_dictionary_handle(doc: &mut acadrust::CadDocument) -> Handle { - let root_h = root_named_dict_handle(doc); - let existing = match doc.objects.get(&root_h) { - Some(ObjectType::Dictionary(root)) => root - .entries - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case(XRECORD_DICT_NAME)) - .map(|(_, handle)| *handle), - _ => None, - }; - match existing.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))) { - Some(handle) => handle, - None => { - let handle = doc.allocate_handle(); - let mut dict = Dictionary::new(); - dict.handle = handle; - dict.owner = root_h; - doc.objects.insert(handle, ObjectType::Dictionary(dict)); - if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { - root.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(XRECORD_DICT_NAME)); - root.add_entry(XRECORD_DICT_NAME, handle); - } - handle - } - } -} - -fn xrecord_owner_dictionary( - doc: &acadrust::CadDocument, - record_handle: Handle, - owner: Handle, -) -> Option { - let owns_record = |handle: Handle| { - matches!(doc.objects.get(&handle), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(_, child)| *child == record_handle)) - }; - if owns_record(owner) { - return Some(owner); - } - doc.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Dictionary(dict) - if dict.entries.iter().any(|(_, child)| *child == record_handle) => - { - Some(*handle) - } - _ => None, - }) -} - -/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by -// the in-process path. Deserializes the envelope, runs the crate executor, -// serializes the `Receipt` (or `ApiError`) back to bytes. -pub fn execute_doc_api( - host: &mut HostSession<'_>, - tab_id: u64, - bytes: &[u8], -) -> Result, String> { - use bincode::Options; - use ocs_doc_api::{DocApiEnvelope, EnvelopeBody}; - // Authorization: the request must name the SAME tab the HostSession is bound to. - // The HostSession is built per-tab by the dispatch pump; a plugin naming a - // different tab_id would otherwise reach a tab it isn't bound to (confused deputy). - if tab_id != host.tab_id() { - return Err(format!( - "DocApi tab mismatch: request names tab {tab_id} but the bound tab is {}", - host.tab_id() - )); - } - host.scene_mut().doc_api_cold_tess_used = 0; - const FRAME_LIMIT: u64 = 64 * 1024 * 1024; - if bytes.len() as u64 > FRAME_LIMIT { - return Err("DocApi frame exceeds 64 MiB".into()); - } - let envelope: DocApiEnvelope = bincode::DefaultOptions::new() - .with_fixint_encoding() - .with_limit(FRAME_LIMIT) - .reject_trailing_bytes() - .deserialize(bytes) - .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; - let result: ApiResult = - envelope - .validate_version() - .and_then(|_| match envelope.body { - EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op), - EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs), - }); - bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}")) -} - -// ObjectId <-> acadrust::Handle converters delegate to the crate's helpers -// (single source of truth; no local duplicates). -fn obj_to_handle(id: ObjectId) -> Handle { - id.to_handle() -} -fn handle_to_obj(h: Handle) -> ObjectId { - ObjectId::from_handle(h) -} - -fn v3_to_array(v: acadrust::types::Vector3) -> [f64; 3] { - [v.x, v.y, v.z] -} - -impl DocApiBackend for HostSession<'_> { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - // Lift-on-miss: populate the solid_models cache from AcisData if absent. - self.scene_mut().restore_solid_models(&[handle]); - self.scene() - .solid_models - .get(&handle) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - - fn with_body( - &mut self, - id: ObjectId, - f: &mut dyn FnMut(&KernelBody) -> ApiResult, - ) -> ApiResult { - let handle = obj_to_handle(id); - // Lift-on-miss, then borrow the cache entry in place — O(1), no deep clone. - self.scene_mut().restore_solid_models(&[handle]); - let body = self - .scene() - .solid_models - .get(&handle) - .ok_or(ApiError::UnknownId(id))?; - f(body) - } - - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let prepared = self.prepare_doc_solid(body.clone(), None)?; - Ok(self.apply_doc_entity(prepared, None)) - } - - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - self.can_modify(id)?; - let prepared = self.prepare_doc_solid(body.clone(), Some(id))?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut prepared = Vec::with_capacity(specs.len()); - for spec in specs { - prepared.push(match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)? - } - ocs_doc_api::EntitySpec::Curve(spec) => { - let mut entity = convert::curve_spec_to_entity(spec)?; - if let Some(name) = spec.layer() { - entity.common_mut().layer = - acadrust::tables::normalize_name(name.trim()); - } - PreparedDocEntity { - entity, - solid: None, - } - } - }); - } - DocApiBackend::push_undo(self, "CreateMany"); - let ids = prepared - .into_iter() - .map(|entity| self.apply_doc_entity(entity, None)) - .collect(); - self.finalize_op(); - Ok(ids) - } - - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let prepared = ids - .iter() - .map(|&id| self.prepare_doc_transform(id, placement)) - .collect::>>()?; - DocApiBackend::push_undo(self, "TransformMany"); - for (&id, entity) in ids.iter().zip(prepared) { - self.apply_doc_entity(entity, Some(id)); - } - self.finalize_op(); - Ok(()) - } - - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer_name = spec.layer(); - if let Some(name) = layer_name { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateCurve", - e.to_string(), - ))?; - } - let mut entity = convert::curve_spec_to_entity(spec)?; - if let Some(name) = layer_name { - entity.common_mut().layer = acadrust::tables::normalize_name(name.trim()); - } - let handle = self.scene_mut().add_entity(entity); - Ok(handle_to_obj(handle)) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = acadrust::tables::normalize_name(name.trim()); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.document() - .layers - .get(&norm) - .map(|l| layer_info_from_acadrust(l)) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - // Validate the referenced block exists before committing. - if self - .document() - .block_records - .get(&spec.block_name) - .is_none() - { - return Err(ApiError::validation( - "CreateInsert", - format!("unknown block {:?}", spec.block_name), - )); - } - let mut ins = acadrust::entities::Insert::new( - spec.block_name.clone(), - acadrust::types::Vector3::new( - spec.insert_point[0], - spec.insert_point[1], - spec.insert_point[2], - ), - ); - ins.rotation = spec.rotation; - ins.set_x_scale(spec.scale); - ins.set_y_scale(spec.scale); - ins.set_z_scale(spec.scale); - let handle = self.scene_mut().add_entity(EntityType::Insert(ins)); - Ok(handle_to_obj(handle)) - } - - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - use acadrust::entities::Viewport; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut vp = Viewport::new(); - vp.center = v3(spec.center); - vp.width = spec.width; - vp.height = spec.height; - vp.view_target = v3(spec.view_target); - vp.view_height = spec.view_height; - let handle = self.scene_mut().add_entity(EntityType::Viewport(vp)); - Ok(handle_to_obj(handle)) - } - - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - use acadrust::entities::Text; - use acadrust::types::Vector3; - let mut t = Text::new(); - t.value = spec.value.clone(); - t.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - t.height = spec.height; - t.rotation = spec.rotation; - let handle = self.scene_mut().add_entity(EntityType::Text(t)); - Ok(handle_to_obj(handle)) - } - - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - use acadrust::entities::MText; - use acadrust::types::Vector3; - let mut t = MText::with_value( - spec.value.clone(), - Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - ); - t.height = spec.height; - let handle = self.scene_mut().add_entity(EntityType::MText(t)); - Ok(handle_to_obj(handle)) - } - - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - let new_entity = match entity { - EntityType::Text(mut t) => { - t.value = value.to_string(); - EntityType::Text(t) - } - EntityType::MText(mut t) => { - t.value = value.to_string(); - EntityType::MText(t) - } - _ => { - return Err(ApiError::Unsupported( - "SetTextContent is only for Text/MText".into(), - )) - } - }; - if !self.scene_mut().update_entity(new_entity) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn text_content(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - match entity { - EntityType::Text(t) => Ok(t.value.clone()), - EntityType::MText(t) => Ok(t.value.clone()), - _ => Err(ApiError::Unsupported( - "GetTextContent is only for Text/MText".into(), - )), - } - } - - fn can_modify(&self, id: ObjectId) -> ApiResult<()> { - let handle = obj_to_handle(id); - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - use acadrust::entities::{BoundaryEdge, BoundaryPath, Hatch, LineEdge}; - use acadrust::types::Vector2; - let mut hatch = if spec.solid { - Hatch::solid() - } else { - Hatch::default() - }; - // Build a BoundaryPath of Line edges around the closed polyline. - let n = spec.boundary.len(); - let mut path = BoundaryPath::new(); - for i in 0..n { - let start = spec.boundary[i]; - let end = spec.boundary[(i + 1) % n]; - path.edges.push(BoundaryEdge::Line(LineEdge { - start: Vector2::new(start[0], start[1]), - end: Vector2::new(end[0], end[1]), - })); - } - hatch.paths.push(path); - let handle = self.scene_mut().add_entity(EntityType::Hatch(hatch)); - Ok(handle_to_obj(handle)) - } - - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Hatch(h) = entity else { - return Err(ApiError::Unsupported( - "GetHatchBoundary is only for Hatch".into(), - )); - }; - // Reconstruct each boundary loop's vertices from its Line edges. - let mut loops = Vec::with_capacity(h.paths.len()); - for path in &h.paths { - let mut pts = Vec::with_capacity(path.edges.len()); - for edge in &path.edges { - match edge { - acadrust::entities::BoundaryEdge::Line(le) => { - pts.push([le.start.x, le.start.y]) - } - // Arc/ellipse boundary edges are a later refinement (no vertex list). - _ => { - return Err(ApiError::Unsupported( - "hatch boundary contains non-line edges".into(), - )) - } - } - } - loops.push(pts); - } - Ok(loops) - } - - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionLinear}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut dim = DimensionLinear::new(v3(spec.first_point), v3(spec.second_point)); - dim.definition_point = v3(spec.definition_point); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Linear(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionRadius}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let dim = DimensionRadius::new(v3(spec.center), v3(spec.point)); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Radius(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionDiameter}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let dim = DimensionDiameter::new(v3(spec.center), v3(spec.point)); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Diameter(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_angular( - &mut self, - spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionAngular3Pt}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut dim = - DimensionAngular3Pt::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point)); - dim.definition_point = v3(spec.arc_location); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Angular3Pt(dim))); - Ok(handle_to_obj(handle)) - } - - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Dimension(d) = entity else { - return Err(ApiError::Unsupported( - "GetDimensionMeasurement is only for Dimension".into(), - )); - }; - // Distance for linear/radius/diameter; degrees for angular. - Ok(match d { - acadrust::entities::Dimension::Linear(x) => x.measurement(), - acadrust::entities::Dimension::Radius(x) => x.measurement(), - acadrust::entities::Dimension::Diameter(x) => x.measurement(), - acadrust::entities::Dimension::Angular3Pt(x) => x.measurement_degrees(), - acadrust::entities::Dimension::Angular2Ln(x) => x.measurement_degrees(), - _ => { - return Err(ApiError::Unsupported( - "dimension measurement for this sub-type".into(), - )) - } - }) - } - - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - use acadrust::entities::AttributeDefinition; - use acadrust::types::Vector3; - let att = AttributeDefinition { - tag: spec.tag.clone(), - prompt: spec.prompt.clone(), - default_value: spec.default_value.clone(), - insertion_point: Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - height: spec.height, - rotation: spec.rotation, - ..Default::default() - }; - let handle = self - .scene_mut() - .add_entity(EntityType::AttributeDefinition(att)); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_angular2ln( - &mut self, - spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionAngular2Ln}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut dim = - DimensionAngular2Ln::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point)); - dim.dimension_arc = v3(spec.arc_location); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Angular2Ln(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - use acadrust::entities::Table; - use acadrust::types::Vector3; - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - let mut table = Table::new( - Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - spec.data.len(), - cols, - ); - // Fill cells from the grid (each cell = text). - for (r, row) in spec.data.iter().enumerate() { - for (c, text) in row.iter().enumerate() { - table.set_cell_text(r, c, text); - } - } - let handle = self.scene_mut().add_entity(EntityType::Table(table)); - Ok(handle_to_obj(handle)) - } - - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Insert(mut ins) = entity else { - return Err(ApiError::Unsupported( - "SetAttribute is only for Insert entities".into(), - )); - }; - if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { - attr.value = value.to_string(); - } else { - let attr = acadrust::entities::AttributeEntity { - tag: tag.to_string(), - value: value.to_string(), - insertion_point: ins.insert_point, - ..Default::default() - }; - ins.attributes.push(attr); - } - if !self.scene_mut().update_entity(EntityType::Insert(ins)) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn attributes(&self, id: ObjectId) -> ApiResult> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Insert(ins) = entity else { - return Err(ApiError::Unsupported( - "GetAttributes is only for Insert entities".into(), - )); - }; - Ok(ins - .attributes - .iter() - .map(|a| (a.tag.clone(), a.value.clone())) - .collect()) - } - - fn block_entities(&self, block_name: &str) -> ApiResult> { - let br = self - .document() - .block_records - .get(block_name) - .ok_or_else(|| { - ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")) - })?; - // Read-only traversal of the block definition's entities (handle lookups). - let mut out = Vec::with_capacity(br.entity_handles.len()); - for h in &br.entity_handles { - let id = handle_to_obj(*h); - let (kind, bounds) = match self.document().get_entity(*h) { - Some(e) => ( - convert::entity_kind_name(e).to_string(), - convert::entity_bounds(Some(e), id).ok(), - ), - None => ("Missing".to_string(), None), - }; - let layer = match self.document().get_entity(*h) { - Some(e) => e.common().layer.clone(), - None => String::new(), - }; - out.push(EntityView { - id, - kind, - layer, - bounds, - }); - } - Ok(out) - } - - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Viewport(mut vp) = entity else { - return Err(ApiError::Unsupported( - "SetViewportView is only for Viewport".into(), - )); - }; - vp.view_target = - acadrust::types::Vector3::new(view_target[0], view_target[1], view_target[2]); - vp.view_height = view_height; - if !self.scene_mut().update_entity(EntityType::Viewport(vp)) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Viewport(vp) = entity else { - return Err(ApiError::Unsupported( - "GetViewportView is only for Viewport".into(), - )); - }; - Ok(( - [vp.view_target.x, vp.view_target.y, vp.view_target.z], - vp.view_height, - )) - } - - fn add_raster_image( - &mut self, - spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - use acadrust::entities::RasterImage; - use acadrust::types::{Vector2, Vector3}; - // Validate the plugin-supplied path before embedding it in the document: - // non-empty, a known image extension, and not a UNC/network path or - // parent-traversal (which could point the host at unintended resources). - validate_image_path(&spec.file_path)?; - let img = RasterImage { - file_path: spec.file_path.clone(), - insertion_point: Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - u_vector: Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]), - v_vector: Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]), - size: Vector2::new(spec.size[0], spec.size[1]), - ..Default::default() - }; - // add_entity auto-registers the ImageDefinition (scene/entity.rs). - let handle = self.scene_mut().add_entity(EntityType::RasterImage(img)); - Ok(handle_to_obj(handle)) - } - - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - let body = cadkernel::brep::loft(sections) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "loft failed"))?; - self.store_solid(&body) - } - - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - self.can_modify(id)?; - let handle = obj_to_handle(id); - let values = record.map(|r| { - r.values - .iter() - .map(|v| xdata_value_to_acadrust(v, self.document())) - .collect::>() - }); - crate::scene::view::dispatch::set_entity_xdata( - self.document_mut(), - handle, - application_name, - values, - ); - Ok(()) - } - - fn xdata(&self, id: ObjectId, application_name: &str) -> ApiResult> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let Some(rec) = entity.common().extended_data.get_record(application_name) else { - return Ok(None); - }; - Ok(Some(ocs_doc_api::XDataRecord { - application_name: rec.application_name.clone(), - values: rec - .values - .iter() - .map(xdata_value_from_acadrust) - .collect(), - })) - } - - fn object_exists(&self, id: ObjectId) -> bool { - let handle = obj_to_handle(id); - self.document().objects.contains_key(&handle) - } - - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - use acadrust::objects::{ObjectType, XRecord}; - let dict_h = xrecord_dictionary_handle(self.document_mut()); - if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, _)| name.eq_ignore_ascii_case(&spec.name))) - { - return Err(ApiError::validation( - "CreateXRecord", - format!("XRecord '{}' already exists", spec.name), - )); - } - let mut record = XRecord::named(spec.name.clone()); - record.owner = dict_h; - record.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); - record.entries = spec - .entries - .iter() - .map(xrecord_entry_to_acadrust) - .collect(); - record.synchronize_object_references(); - let record_handle = { - let doc = self.document_mut(); - let handle = doc.allocate_handle(); - record.handle = handle; - doc.objects - .insert(handle, ObjectType::XRecord(record)); - handle - }; - // Attach the new XRecord to the stable named-object dictionary so it is - // reachable as a standalone named object. - if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.add_entry(spec.name.clone(), record_handle); - } - Ok(handle_to_obj(record_handle)) - } - - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - let handle = obj_to_handle(id); - let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { - return Err(ApiError::Unsupported(format!("ObjectId {id:?} is not an XRecord"))); - }; - let mut updated = record.clone(); - let dict_h = xrecord_owner_dictionary(self.document(), handle, record.owner) - .unwrap_or_else(|| xrecord_dictionary_handle(self.document_mut())); - if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, child)| { - *child != handle && name.eq_ignore_ascii_case(&spec.name) - })) - { - return Err(ApiError::validation( - "SetXRecord", - format!("XRecord '{}' already exists", spec.name), - )); - } - updated.name = spec.name.clone(); - updated.owner = dict_h; - updated.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); - updated.entries = spec - .entries - .iter() - .map(xrecord_entry_to_acadrust) - .collect(); - updated.synchronize_object_references(); - self.document_mut() - .objects - .insert(handle, acadrust::objects::ObjectType::XRecord(updated)); - if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.entries.retain(|(_, child)| *child != handle); - dict.add_entry(spec.name.clone(), handle); - } - Ok(()) - } - - fn xrecord(&self, id: ObjectId) -> ApiResult> { - let handle = obj_to_handle(id); - let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { - return Ok(None); - }; - Ok(Some(ocs_doc_api::XRecordSpec { - name: record.name.clone(), - cloning_flags: cloning_flags_from_acadrust(record.cloning_flags), - entries: record - .entries - .iter() - .map(xrecord_entry_from_acadrust) - .collect(), - })) - } - - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = acadrust::tables::normalize_name(info.name.trim()); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.document().layers.contains(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut layer = acadrust::tables::Layer::new(&name); - layer.handle = self.document_mut().allocate_handle(); - apply_layer_info(&mut layer, info); - self.document_mut() - .layers - .add(layer) - .map_err(|e| ApiError::validation("CreateLayer", e))?; - Ok(()) - } - - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let deps = { - let doc = self.document_mut(); - let name_upper = acadrust::tables::normalize_name(name.trim()); - let Some(existing) = doc.layers.get(&name_upper).cloned() else { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - }; - let new_name = acadrust::tables::normalize_name(info.name.trim()); - if new_name.is_empty() { - return Err(ApiError::validation("UpdateLayer", "empty layer name")); - } - // Rename if the key changed (preserves handle and table position). - if name_upper != new_name { - doc.layers - .rename(&name_upper, new_name.clone()) - .map_err(|e| ApiError::validation("UpdateLayer", e))?; - } - let Some(layer) = doc.layers.get_mut(&new_name) else { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - }; - apply_layer_info(layer, info); - [existing.name.clone(), layer.name.clone()] - }; - // By-layer appearance changed: rebuild derived geometry that resolved it. - self.scene_mut().invalidate_layer_dependencies(&deps); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = acadrust::tables::normalize_name(name.trim()); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if !self.document().layers.contains(&name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - if acadrust::tables::normalize_name(&self.document().header.current_layer_name) == name_upper - { - return Err(ApiError::validation( - "DeleteLayer", - "cannot delete the current layer", - )); - } - if self - .document() - .entities() - .any(|e| acadrust::tables::normalize_name(&e.common().layer) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' is still in use"), - )); - } - // Remove by the normalized name so a differently-cased request behaves - // consistently with the rest of the layer table API. - self.document_mut().layers.remove(&name_upper); - Ok(()) - } - - - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - self.can_modify(id)?; - let handle = obj_to_handle(id); - let layer_upper = acadrust::tables::normalize_name(layer.trim()); - let target = self.document().layers.get(&layer_upper).cloned(); - let Some(target) = target else { - return Err(ApiError::validation( - "SetEntityLayer", - format!("layer '{layer}' does not exist"), - )); - }; - if target.is_locked() { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer}'" - ))); - } - let mut entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - entity.common_mut().layer = target.name.clone(); - if !self.scene_mut().update_entity(entity) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self - .document() - .layers - .iter() - .map(layer_info_from_acadrust) - .collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - - fn entity_layer(&self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - Ok(entity.common().layer.clone()) - } - - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let doc = self.document(); - let mut out = Vec::new(); - for entity in doc.entities() { - let handle = entity.common().handle; - let id = handle_to_obj(handle); - let entity_kind = convert::entity_kind_name(entity); - if let Some(k) = kind { - if !entity_kind.eq_ignore_ascii_case(k) { - continue; - } - } - if let Some(l) = layer { - if acadrust::tables::normalize_name(&entity.common().layer) - != acadrust::tables::normalize_name(l.trim()) - { - continue; - } - } - out.push(EntityView { - id, - kind: entity_kind.to_string(), - layer: entity.common().layer.clone(), - bounds: if include_bounds { - convert::entity_bounds(Some(entity), id).ok() - } else { - None - }, - }); - } - Ok(out) - } - - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Point(pt) = entity else { - return Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )); - }; - Ok(v3_to_array(pt.location)) - } - - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Line(line) = entity else { - return Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )); - }; - Ok((v3_to_array(line.start), v3_to_array(line.end))) - } - - fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { - let handle = obj_to_handle(id); - let Some(entity) = self.document().get_entity(handle).cloned() else { - return Err(ApiError::UnknownId(id)); - }; - let EntityType::LwPolyline(mut pl) = entity else { - return Err(ApiError::Unsupported( - "AddVertex is only implemented for polylines".into(), - )); - }; - if point[2] != pl.elevation || pl.vertices.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - return Err(ApiError::validation( - "AddVertex", - "vertex must share the polyline elevation and fit the item cap", - )); - } - if at > pl.vertices.len() { - return Err(ApiError::validation( - "AddVertex", - format!("index {at} out of range ({} vertices)", pl.vertices.len()), - )); - } - pl.vertices.insert( - at, - acadrust::entities::LwVertex::from_coords(point[0], point[1]), - ); - if !self.scene_mut().update_entity(EntityType::LwPolyline(pl)) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - if !self.entity_exists(id) { - return Ok(false); - } - // Locked layers refuse deletion; surface that as an error so the executor - // does not treat a no-op as success. - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - // erase_entities clears the entity + solid_models/meshes/hatches and - // records the undo delta; the single publish happens at finalize_op. - self.scene_mut().erase_entities(&[handle]); - Ok(self.document().get_entity(handle).is_none()) - } - - fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - // Pre-validating everything the apply loop can actually fail on is what makes - // TransformMany all-or-nothing (no mid-loop failure after earlier mutations). - // This must check more than the entity TYPE: layer-lock (update_entity returns - // false) and, for solids, that the body resolves. - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let ok = matches!( - entity, - EntityType::Solid3D(_) - | EntityType::Line(_) - | EntityType::Circle(_) - | EntityType::Arc(_) - | EntityType::Ellipse(_) - | EntityType::Spline(_) - | EntityType::Ray(_) - | EntityType::XLine(_) - | EntityType::Insert(_) - | EntityType::Viewport(_) - | EntityType::Text(_) - | EntityType::MText(_) - | EntityType::Point(_) - | EntityType::LwPolyline(_) - ); - if !ok { - return Err(ApiError::Unsupported( - "transform is not supported for this entity family".into(), - )); - } - // Locked layer -> update_entity would return false mid-loop. - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - // Solid: the body must resolve (kernel transform/display-prep can still fail - // at apply time, but resolution is the checkable precondition). - if matches!(entity, EntityType::Solid3D(_)) { - self.resolve_body(id)?; - } - Ok(()) - } - - fn can_remove(&self, id: ObjectId) -> ApiResult<()> { - let handle = obj_to_handle(id); - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - Ok(EntityView { - id, - kind: convert::entity_kind_name(entity).to_string(), - layer: entity.common().layer.clone(), - bounds: self.bounds(id).ok(), - }) - } - - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - let prepared = self.prepare_doc_transform(id, placement)?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn profile_curves(&self, id: ObjectId) -> ApiResult> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.pieces) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn profile_plane(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.plane) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn bounds(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - // Lift-on-miss for solids (consistent with volume/centroid); borrow the cache - // read-only via with_body (O(1), no B-rep deep clone). - if matches!( - self.document().get_entity(handle), - Some(EntityType::Solid3D(_)) - ) { - let mut f = |body: &KernelBody| -> ApiResult { - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) - }; - return self.with_body(id, &mut f); - } - convert::entity_bounds(self.document().get_entity(handle), id) - } - - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - Ok(self.mass_properties(id)?.1) - } - - fn volume(&mut self, id: ObjectId) -> ApiResult { - Ok(self.mass_properties(id)?.0) - } - - fn entity_exists(&self, id: ObjectId) -> bool { - self.document().get_entity(obj_to_handle(id)).is_some() - } - - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.scene().geometry_epoch) - } - - fn push_undo(&mut self, label: &str) { - // Begin undo capture for an entity-only delta (delta_safe = true: solids - // and curves are entity-store changes). - self.begin_doc_api_undo(label); - } - - fn cancel_op(&mut self) { - self.cancel_doc_api_undo(); - } - - fn finalize_op(&mut self) { - // Close the delta entry + bump geometry + republish the document view. - self.commit_doc_api_undo(); - } -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -type SolidDisplay = ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], -); - -struct PreparedDocEntity { - entity: EntityType, - solid: Option<(KernelBody, SolidDisplay)>, -} - -impl HostSession<'_> { - fn prepare_doc_solid( - &self, - body: KernelBody, - id: Option, - ) -> ApiResult { - let mut inner = match id { - Some(id) => match self.document().get_entity(obj_to_handle(id)) { - Some(EntityType::Solid3D(solid)) => solid.clone(), - _ => return Err(ApiError::UnknownId(id)), - }, - None => Solid3D::new(), - }; - let handle = id.map_or(Handle::NULL, obj_to_handle); - let display = self - .scene() - .prepare_solid_model_display(handle, &body) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Other, "solid display preparation failed") - })?; - let sat = acis_export::solid_to_sat(&body).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Acis, "solid serialization failed") - })?; - inner.wires = solid_model::edge_wires(&body); - inner.set_sat_document(&sat); - Ok(PreparedDocEntity { - entity: EntityType::Solid3D(inner), - solid: Some((body, display)), - }) - } - - fn prepare_doc_transform( - &mut self, - id: ObjectId, - placement: &PlacementSpec, - ) -> ApiResult { - self.ensure_transformable(id)?; - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Solid3D(_)) { - let body = self.resolve_body(id)?; - let moved = cadkernel::brep::transform(&body, &kernel_placement(placement)) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.prepare_doc_solid(moved, Some(id)) - } else { - Ok(PreparedDocEntity { - entity: convert::transform_entity_geometry(entity, placement)?, - solid: None, - }) - } - } - - fn apply_doc_entity(&mut self, prepared: PreparedDocEntity, id: Option) -> ObjectId { - let handle = match id { - Some(id) => { - assert!( - self.scene_mut().update_entity(prepared.entity), - "prevalidated entity update" - ); - obj_to_handle(id) - } - None => self.scene_mut().add_entity(prepared.entity), - }; - if let Some((body, display)) = prepared.solid { - self.scene_mut() - .register_prepared_solid_model(handle, body, display); - } - handle_to_obj(handle) - } - - /// Kernel mesh mass properties, cached per entity and geometry epoch. - fn mass_properties(&mut self, id: ObjectId) -> ApiResult<(f64, [f64; 3])> { - let handle = obj_to_handle(id); - let epoch = self.scene().geometry_epoch; - if let Some(&(cached_epoch, v, c)) = self.scene().doc_api_mass_cache.get(&handle) { - if cached_epoch == epoch { - return Ok((v, c)); - } - } - if self.scene().doc_api_cold_tess_used >= 32 { - return Err(ApiError::validation( - "mass properties", - "request exceeds 32 uncached solid queries", - )); - } - let body = self.resolve_body(id)?; - self.scene_mut().doc_api_cold_tess_used += 1; - let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4); - let (v, c) = mesh.mass_properties().ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Empty, "solid has no measurable volume") - })?; - if self.scene().doc_api_mass_cache.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - self.scene_mut().doc_api_mass_cache.clear(); - } - self.scene_mut() - .doc_api_mass_cache - .insert(handle, (epoch, v, c)); - Ok((v, c)) - } -} - -/// Validate a raster-image file path before embedding it in a document. -/// Rejects empty paths, UNC/network paths, parent-traversal, and non-image extensions. -fn validate_image_path(path: &str) -> ApiResult<()> { - let p = path.trim(); - if p.is_empty() { - return Err(ApiError::validation( - "CreateRasterImage", - "empty image file path", - )); - } - // UNC / network / device paths. - if p.starts_with("\\\\") || p.starts_with("//") { - return Err(ApiError::Unsupported( - "network/UNC image paths are not allowed".into(), - )); - } - // Parent-traversal anywhere in the path. - if p.split(['/', '\\']).any(|seg| seg == "..") { - return Err(ApiError::Unsupported( - "parent-traversal ('..') in image path is not allowed".into(), - )); - } - // Known image extension (last component). - let ext_ok = p - .rsplit('.') - .next() - .map(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "tga" - ) - }) - .unwrap_or(false); - if !ext_ok { - return Err(ApiError::validation( - "CreateRasterImage", - format!("unsupported image file extension in {path:?}"), - )); - } - Ok(()) -} - -fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { - cadkernel::brep::Placement { - x_axis: p.x_axis, - y_axis: p.y_axis, - z_axis: p.z_axis, - origin: p.origin, - } -} - -// Conversions between ocs_doc_api plain-data DTOs and acadrust types. -// Kept in this file because they are host-side implementation details. - -fn cloning_flags_to_acadrust( - flags: ocs_doc_api::XRecordCloningFlags, -) -> acadrust::objects::DictionaryCloningFlags { - use acadrust::objects::DictionaryCloningFlags as F; - match flags { - ocs_doc_api::XRecordCloningFlags::NotApplicable => F::NotApplicable, - ocs_doc_api::XRecordCloningFlags::KeepExisting => F::KeepExisting, - ocs_doc_api::XRecordCloningFlags::UseClone => F::UseClone, - ocs_doc_api::XRecordCloningFlags::XrefName => F::XrefName, - ocs_doc_api::XRecordCloningFlags::Name => F::Name, - ocs_doc_api::XRecordCloningFlags::UnmangleName => F::UnmangleName, - } -} - -fn cloning_flags_from_acadrust( - flags: acadrust::objects::DictionaryCloningFlags, -) -> ocs_doc_api::XRecordCloningFlags { - use acadrust::objects::DictionaryCloningFlags as F; - match flags { - F::NotApplicable => ocs_doc_api::XRecordCloningFlags::NotApplicable, - F::KeepExisting => ocs_doc_api::XRecordCloningFlags::KeepExisting, - F::UseClone => ocs_doc_api::XRecordCloningFlags::UseClone, - F::XrefName => ocs_doc_api::XRecordCloningFlags::XrefName, - F::Name => ocs_doc_api::XRecordCloningFlags::Name, - F::UnmangleName => ocs_doc_api::XRecordCloningFlags::UnmangleName, - } -} - -fn xdata_value_to_acadrust( - value: &ocs_doc_api::XDataValue, - _doc: &acadrust::CadDocument, -) -> acadrust::xdata::XDataValue { - use acadrust::types::Vector3; - use ocs_doc_api::XDataValue as V; - match value { - V::String(s) => acadrust::xdata::XDataValue::String(s.clone()), - V::ControlString(s) => acadrust::xdata::XDataValue::ControlString(s.clone()), - V::LayerName(s) => acadrust::xdata::XDataValue::LayerName(s.clone()), - V::BinaryData(b) => acadrust::xdata::XDataValue::BinaryData(b.clone()), - V::Handle(h) => acadrust::xdata::XDataValue::Handle(acadrust::Handle::new(*h)), - V::Point3D(p) => acadrust::xdata::XDataValue::Point3D(Vector3::new(p[0], p[1], p[2])), - V::Position3D(p) => acadrust::xdata::XDataValue::Position3D(Vector3::new(p[0], p[1], p[2])), - V::Displacement3D(p) => { - acadrust::xdata::XDataValue::Displacement3D(Vector3::new(p[0], p[1], p[2])) - } - V::Direction3D(p) => acadrust::xdata::XDataValue::Direction3D(Vector3::new(p[0], p[1], p[2])), - V::Real(r) => acadrust::xdata::XDataValue::Real(*r), - V::Distance(d) => acadrust::xdata::XDataValue::Distance(*d), - V::ScaleFactor(s) => acadrust::xdata::XDataValue::ScaleFactor(*s), - V::Integer16(i) => acadrust::xdata::XDataValue::Integer16(*i), - V::Integer32(i) => acadrust::xdata::XDataValue::Integer32(*i), - } -} - -fn xdata_value_from_acadrust(value: &acadrust::xdata::XDataValue) -> ocs_doc_api::XDataValue { - use acadrust::xdata::XDataValue as V; - match value { - V::String(s) => ocs_doc_api::XDataValue::String(s.clone()), - V::ControlString(s) => ocs_doc_api::XDataValue::ControlString(s.clone()), - V::LayerName(s) => ocs_doc_api::XDataValue::LayerName(s.clone()), - V::BinaryData(b) => ocs_doc_api::XDataValue::BinaryData(b.clone()), - V::Handle(h) => ocs_doc_api::XDataValue::Handle(h.value()), - V::Point3D(v) => ocs_doc_api::XDataValue::Point3D([v.x, v.y, v.z]), - V::Position3D(v) => ocs_doc_api::XDataValue::Position3D([v.x, v.y, v.z]), - V::Displacement3D(v) => ocs_doc_api::XDataValue::Displacement3D([v.x, v.y, v.z]), - V::Direction3D(v) => ocs_doc_api::XDataValue::Direction3D([v.x, v.y, v.z]), - V::Real(r) => ocs_doc_api::XDataValue::Real(*r), - V::Distance(d) => ocs_doc_api::XDataValue::Distance(*d), - V::ScaleFactor(s) => ocs_doc_api::XDataValue::ScaleFactor(*s), - V::Integer16(i) => ocs_doc_api::XDataValue::Integer16(*i), - V::Integer32(i) => ocs_doc_api::XDataValue::Integer32(*i), - } -} - -fn xrecord_value_to_acadrust(value: &ocs_doc_api::XRecordValue) -> acadrust::objects::XRecordValue { - use ocs_doc_api::XRecordValue as V; - match value { - V::String(s) => acadrust::objects::XRecordValue::String(s.clone()), - V::Double(d) => acadrust::objects::XRecordValue::Double(*d), - V::Int16(i) => acadrust::objects::XRecordValue::Int16(*i), - V::Int32(i) => acadrust::objects::XRecordValue::Int32(*i), - V::Int64(i) => acadrust::objects::XRecordValue::Int64(*i), - V::Byte(b) => acadrust::objects::XRecordValue::Byte(*b), - V::Bool(b) => acadrust::objects::XRecordValue::Bool(*b), - V::Handle(h) => acadrust::objects::XRecordValue::Handle(acadrust::Handle::new(*h)), - V::Point3D(p) => acadrust::objects::XRecordValue::Point3D(p[0], p[1], p[2]), - V::Chunk(c) => acadrust::objects::XRecordValue::Chunk(c.clone()), - } -} - -fn xrecord_value_from_acadrust( - value: &acadrust::objects::XRecordValue, -) -> ocs_doc_api::XRecordValue { - use acadrust::objects::XRecordValue as V; - match value { - V::String(s) => ocs_doc_api::XRecordValue::String(s.clone()), - V::Double(d) => ocs_doc_api::XRecordValue::Double(*d), - V::Int16(i) => ocs_doc_api::XRecordValue::Int16(*i), - V::Int32(i) => ocs_doc_api::XRecordValue::Int32(*i), - V::Int64(i) => ocs_doc_api::XRecordValue::Int64(*i), - V::Byte(b) => ocs_doc_api::XRecordValue::Byte(*b), - V::Bool(b) => ocs_doc_api::XRecordValue::Bool(*b), - V::Handle(h) => ocs_doc_api::XRecordValue::Handle(h.value()), - V::Point3D(x, y, z) => ocs_doc_api::XRecordValue::Point3D([*x, *y, *z]), - V::Chunk(c) => ocs_doc_api::XRecordValue::Chunk(c.clone()), - } -} - -fn xrecord_entry_to_acadrust( - entry: &ocs_doc_api::XRecordEntry, -) -> acadrust::objects::XRecordEntry { - acadrust::objects::XRecordEntry { - code: entry.code, - value: xrecord_value_to_acadrust(&entry.value), - } -} - -fn xrecord_entry_from_acadrust( - entry: &acadrust::objects::XRecordEntry, -) -> ocs_doc_api::XRecordEntry { - ocs_doc_api::XRecordEntry { - code: entry.code, - value: xrecord_value_from_acadrust(&entry.value), - } -} - -// ── layer conversions ───────────────────────────────────────────────────── - -fn color_to_acadrust(c: Color) -> acadrust::types::Color { - use acadrust::types::Color as A; - match c { - Color::ByLayer => A::ByLayer, - Color::None => A::None, - Color::ByBlock => A::ByBlock, - Color::Index(i) => A::Index(i), - Color::Rgb { r, g, b } => A::Rgb { r, g, b }, - } -} - -fn color_from_acadrust(c: acadrust::types::Color) -> Color { - use acadrust::types::Color as A; - match c { - A::ByLayer => Color::ByLayer, - A::None => Color::None, - A::ByBlock => Color::ByBlock, - A::Index(i) => Color::Index(i), - A::Rgb { r, g, b } => Color::Rgb { r, g, b }, - } -} - -fn line_weight_to_acadrust(lw: LineWeight) -> acadrust::types::LineWeight { - use acadrust::types::LineWeight as A; - match lw { - LineWeight::ByLayer => A::ByLayer, - LineWeight::ByBlock => A::ByBlock, - LineWeight::Default => A::Default, - LineWeight::Value(v) => A::Value(v), - } -} - -fn line_weight_from_acadrust(lw: acadrust::types::LineWeight) -> LineWeight { - use acadrust::types::LineWeight as A; - match lw { - A::ByLayer => LineWeight::ByLayer, - A::ByBlock => LineWeight::ByBlock, - A::Default => LineWeight::Default, - A::Value(v) => LineWeight::Value(v), - } -} - -fn layer_flags_to_acadrust(f: LayerFlags) -> acadrust::tables::LayerFlags { - acadrust::tables::LayerFlags { - frozen: f.frozen, - locked: f.locked, - frozen_in_new_viewport: f.frozen_in_new_viewport, - off: f.off, - xref_dependent: false, - } -} - -fn layer_flags_from_acadrust(f: acadrust::tables::LayerFlags) -> LayerFlags { - LayerFlags { - frozen: f.frozen, - locked: f.locked, - frozen_in_new_viewport: f.frozen_in_new_viewport, - off: f.off, - } -} - -fn apply_layer_info(layer: &mut acadrust::tables::Layer, info: &LayerInfo) { - layer.name = info.name.trim().to_string(); - layer.flags = layer_flags_to_acadrust(info.flags); - layer.color = color_to_acadrust(info.color); - layer.color_name = None; - layer.book_name = None; - layer.line_type = info.line_type.clone(); - layer.line_weight = line_weight_to_acadrust(info.line_weight); - layer.plot_style = info.plot_style.clone(); - layer.is_plottable = info.is_plottable; -} - -fn layer_info_from_acadrust(layer: &acadrust::tables::Layer) -> LayerInfo { - LayerInfo { - name: layer.name.clone(), - flags: layer_flags_from_acadrust(layer.flags), - color: color_from_acadrust(layer.color), - line_type: layer.line_type.clone(), - line_weight: line_weight_from_acadrust(layer.line_weight), - plot_style: layer.plot_style.clone(), - is_plottable: layer.is_plottable, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::plugin_host::HostSession; - use crate::app::OpenCADStudio; - use ocs_doc_api::ops::{ - BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive, XDataRecord, - XDataValue, XRecordEntry, XRecordSpec, XRecordValue, - }; - use ocs_doc_api::{ - DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt, - XRecordCloningFlags, - }; - - fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { - let bytes = bincode::serialize(&env).unwrap(); - let tab_id = host.tab_id(); - let out = execute_doc_api(host, tab_id, &bytes).expect("dispatch failed"); - bincode::deserialize(&out).expect("receipt deserialize") - } - - fn new_id(receipt: &Receipt) -> ObjectId { - receipt - .outcome - .as_ref() - .and_then(|o| o.new_id()) - .expect("no new id") - } - - #[test] - fn doc_api_create_boolean_query_end_to_end() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let rev0 = host.scene().geometry_epoch; - - // op 1 + 2: create two overlapping boxes (one undo step + one bump each). - let cuboid_a = Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - }); - let cuboid_b = Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [5.0; 3], - size: [10.0; 3], - }); - let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_a)).unwrap()); - let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_b)).unwrap()); - // Each write op advanced the geometry epoch (at least one bump per op). - assert!( - host.scene().geometry_epoch > rev0, - "epoch advanced by creates" - ); - - // op 3: intersect; erase_sources keeps the result at `a`, erases `b`. - let intersect = Operation::SolidBoolean { - op: BoolOp::Intersection, - a, - b, - erase_sources: true, - }; - let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect)).unwrap()); - assert_eq!(lens, a); - assert!( - host.document().get_entity(obj_to_handle(b)).is_none(), - "b erased" - ); - assert!( - host.scene().solid_models.contains_key(&obj_to_handle(a)), - "result in cache" - ); - - // query batch: bounds + volume (read-only, no bump). - let rev_before_query = host.scene().geometry_epoch; - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: a }, Query::GetVolume { id: a }]), - ) - .unwrap(); - assert_eq!( - host.scene().geometry_epoch, - rev_before_query, - "queries must not bump" - ); - let bb = match &receipt.query_results[0] { - QueryResult::Bounds(bb) => *bb, - other => panic!("expected bounds, got {other:?}"), - }; - // [0,10]^3 intersect [5,15]^3 = [5,10]^3. - assert!( - (bb.min[0] - 5.0).abs() < 1e-4 && (bb.max[0] - 10.0).abs() < 1e-4, - "bounds {bb:?}" - ); - let vol = match &receipt.query_results[1] { - QueryResult::Volume(v) => *v, - other => panic!("expected volume, got {other:?}"), - }; - assert!((vol - 125.0).abs() < 1.0, "volume {vol}"); - let _ = HasId::id(&lens); - } - - #[test] - fn doc_api_transform_many_with_stale_id_fails_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A transformable solid, plus a stale id (always non-transformable -> UnknownId). - let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - }); - let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap()); - let stale = ObjectId::from_u64(0xFFFF_FFFF); - let rev_before = host.scene().geometry_epoch; - - // TransformMany over [solid, stale]: the stale id makes it fail BEFORE any - // mutation (all-or-nothing), so the epoch must not move and no undo is recorded. - let op = Operation::TransformMany { - ids: vec![solid, stale], - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }; - let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err(); - assert!( - matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), - "{err:?}" - ); - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected TransformMany" - ); - } - - #[test] - fn doc_api_query_batch_over_cap_is_rejected() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; - let queries: Vec = (0..over).map(|_| Query::GetGeometryRevision).collect(); - let err = dispatch(&mut host, DocApiEnvelope::queries(queries)).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - } - - // ── Phase 0: outstanding supported-family methods ────────────────────── - - #[test] - fn phase0_add_vertex_to_polyline() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], - closed: false, - }); - let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_poly)).unwrap()); - // Insert a vertex at index 1. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::AddVertex { - id: poly, - at: 1, - point: [5.0, 0.0, 0.0], - }), - ) - .unwrap(); - // The polyline now has 4 vertices with the inserted one at index 1. - let handle = obj_to_handle(poly); - let Some(EntityType::LwPolyline(pl)) = host.document().get_entity(handle) else { - panic!("polyline not found"); - }; - assert_eq!(pl.vertices.len(), 4); - assert!((pl.vertices[1].location.x - 5.0).abs() < 1e-9); - // Out-of-range index is a validation error, not a panic. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::AddVertex { - id: poly, - at: 99, - point: [0.0, 0.0, 0.0], - }), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - } - - #[test] - fn phase0_extrude_rectangular_profile_to_solid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A closed rectangular 2x3 profile in XY. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [2.0, 3.0, 0.0], - [0.0, 3.0, 0.0], - ], - closed: true, - }); - let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); - // Extrude +Z by 5 -> a 2x3x5 box = volume 30. - let solid = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::Extrude { - profile, - direction: [0.0, 0.0, 5.0], - }), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id: solid }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Volume(v) => assert!((*v - 30.0).abs() < 1.0, "extrude volume {v}"), - other => panic!("expected volume, got {other:?}"), - } - } - - #[test] - fn phase0_revolve_profile_to_solid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y - // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [1.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [2.0, 2.0, 0.0], - [1.0, 2.0, 0.0], - ], - closed: true, - }); - let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Revolve { - profile, - axis: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]), - angle: std::f64::consts::TAU, - }), - ); - // Revolve may be geometry-sensitive; assert it either produces a positive - // volume or surfaces a structured geometry error (no panic). - match result { - Ok(receipt) => { - let id = receipt.outcome.and_then(|o| o.new_id()).unwrap(); - let v = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id }]), - ) - .unwrap(); - match &v.query_results[0] { - QueryResult::Volume(vol) => assert!(*vol > 0.0, "revolve volume {vol}"), - other => panic!("expected volume, got {other:?}"), - } - } - Err(ApiError::Geometry { .. } | ApiError::Validation { .. }) => {} - Err(e) => panic!("unexpected error: {e}"), - } - } - - #[test] - fn phase0_non_solid_transform_line_and_circle() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let mk_line = Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [10.0; 3], - }); - let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); - // Translate the line by +5 in X: bounds shift by +5. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: line, - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: line }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 5.0).abs() < 1e-6 && (b.max[0] - 15.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - // TransformMany over a mix of line + circle is now all-or-nothing (both transformable). - let circle = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0; 3], - radius: 2.0, - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::TransformMany { - ids: vec![line, circle], - placement: ocs_doc_api::PlacementSpec::at([0.0, 1.0, 0.0]), - }), - ) - .unwrap(); - } - - // ── Phase 2: full 2D curve families ──────────────────────────────────── - - #[test] - fn phase2_arc_ellipse_spline_create_bounds_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Arc: create -> kind Arc; bounds are the coarse full-circle bounds. - let arc = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, - centre: [5.0, 5.0, 0.0], - radius: 4.0, - start_angle: 0.0, - end_angle: std::f64::consts::PI, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: arc }, - Query::GetBounds { id: arc }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Arc"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 1.0).abs() < 1e-6 && (b.max[0] - 9.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - - // Ellipse: create -> kind Ellipse; bounds = centre ± major-axis length. - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - centre: [0.0, 0.0, 0.0], - major_axis: [6.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::TAU, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntity { id: ell }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Ellipse"), - other => panic!("expected entity, got {other:?}"), - } - - // Spline (degree-3 cubic through 4 control points): create -> kind Spline; - // bounds = control-point bounds. Transform by +10 X shifts bounds. - let spline = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { layer: None, - degree: 3, - control_points: vec![ - [0.0, 0.0, 0.0], - [1.0, 2.0, 0.0], - [2.0, -2.0, 0.0], - [3.0, 0.0, 0.0], - ], - knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], - weights: vec![1.0; 4], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: spline, - placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => assert!((b.min[0] - 10.0).abs() < 1e-6, "{b:?}"), - other => panic!("expected bounds, got {other:?}"), - } - } - - #[test] - fn phase2_ray_xline_create_and_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let ray = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, - origin: [1.0, 2.0, 0.0], - direction: [1.0, 0.0, 0.0], - })), - ) - .unwrap(), - ); - let xline = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { layer: None, - origin: [0.0, 0.0, 0.0], - direction: [0.0, 1.0, 0.0], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: ray }, - Query::GetEntity { id: xline }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Ray"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Entity(v) => assert_eq!(v.kind, "XLine"), - other => panic!("expected entity, got {other:?}"), - } - // Ray/XLine are unbounded -> GetBounds is Unsupported (not a panic). - assert!(dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: ray }]) - ) - .is_err()); - // Transform moves the ray's base point (no error). - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ray, - placement: ocs_doc_api::PlacementSpec::at([0.0, 0.0, 5.0]), - }), - ) - .unwrap(); - let handle = obj_to_handle(ray); - let Some(EntityType::Ray(r)) = host.document().get_entity(handle) else { - panic!("ray not found") - }; - assert!((r.base_point.z - 5.0).abs() < 1e-6); - } - - // ── Phase 4: paper-space viewports ─────────────────────────────────────── - - #[test] - fn phase4_create_viewport_bounds_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A 40x30 paper-space viewport at (50,50,0) looking at model origin. - let vp = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateViewport(ocs_doc_api::ops::ViewportSpec { - center: [50.0, 50.0, 0.0], - width: 40.0, - height: 30.0, - view_target: [0.0, 0.0, 0.0], - view_height: 100.0, - })), - ) - .unwrap(), - ); - let handle = obj_to_handle(vp); - let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { - panic!("viewport not found") - }; - assert!((v.width - 40.0).abs() < 1e-9 && (v.view_height - 100.0).abs() < 1e-9); - - // Bounds = center ± width/2, height/2. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: vp }, - Query::GetBounds { id: vp }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Viewport"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 30.0).abs() < 1e-6 && (b.max[0] - 70.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 35.0).abs() < 1e-6 && (b.max[1] - 65.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - - // Transform moves the viewport's paper-space center. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: vp, - placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { - panic!("viewport not found") - }; - assert!((v.center.x - 60.0).abs() < 1e-6); - } - - // ── Regression tests for review fixes (s^2 scale, rotation, all-or-nothing) ── - - #[test] - fn fix_uniform_scale_applied_once_not_squared() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let circle = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 0.0], - radius: 1.0, - })), - ) - .unwrap(), - ); - // Uniform scale by 2 (x_axis=(2,0,0) etc). Radius must become 2 (s), not 4 (s^2). - let placement = ocs_doc_api::PlacementSpec { - x_axis: [2.0, 0.0, 0.0], - y_axis: [0.0, 2.0, 0.0], - z_axis: [0.0, 0.0, 2.0], - origin: [0.0; 3], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: circle, - placement, - }), - ) - .unwrap(); - let handle = obj_to_handle(circle); - let Some(EntityType::Circle(c)) = host.document().get_entity(handle) else { - panic!("circle not found") - }; - assert!( - (c.radius - 2.0).abs() < 1e-9, - "radius {} (must be 2.0, not 4.0)", - c.radius - ); - } - - #[test] - fn fix_rotation_rotates_ellipse_ray_and_insert() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // 90-degree Z-rotation: x_axis=(0,1,0), y_axis=(-1,0,0). - let rot90 = ocs_doc_api::PlacementSpec { - x_axis: [0.0, 1.0, 0.0], - y_axis: [-1.0, 0.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0; 3], - }; - // Ray along +X must become along +Y after a 90° rotation. - let ray = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, - origin: [0.0, 0.0, 0.0], - direction: [1.0, 0.0, 0.0], - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ray, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ray(r)) = host.document().get_entity(obj_to_handle(ray)) else { - panic!("ray not found") - }; - assert!( - (r.direction.y - 1.0).abs() < 1e-6 && r.direction.x.abs() < 1e-6, - "ray dir {:?}", - r.direction - ); - - // Ellipse major_axis along +X must rotate to +Y (length preserved). - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - centre: [0.0; 3], - major_axis: [4.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::TAU, - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ell, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { - panic!("ellipse not found") - }; - assert!( - (e.major_axis.y - 4.0).abs() < 1e-6 && e.major_axis.x.abs() < 1e-6, - "ellipse axis {:?}", - e.major_axis - ); - - // Arc angles offset by +90° (π/2). - let arc = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, - centre: [0.0; 3], - radius: 2.0, - start_angle: 0.0, - end_angle: 1.0, - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: arc, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Arc(a)) = host.document().get_entity(obj_to_handle(arc)) else { - panic!("arc not found") - }; - let half_pi = std::f64::consts::FRAC_PI_2; - assert!( - (a.start_angle - half_pi).abs() < 1e-6, - "arc start {} (expected {})", - a.start_angle, - half_pi - ); - } - - #[test] - fn fix_transform_many_locked_layer_fails_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let a = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [0.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [1.0; 3], - })), - ) - .unwrap(), - ); - // Lock the layer holding `b`. - let bh = obj_to_handle(b); - let layer = host - .document() - .get_entity(bh) - .unwrap() - .common() - .layer - .clone(); - host.document_mut().layers.get_mut(&layer).unwrap().lock(); - let rev_before = host.scene().geometry_epoch; - - // TransformMany [a, b] must fail all-or-nothing (b is locked) BEFORE mutating a. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::TransformMany { - ids: vec![a, b], - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }), - ) - .unwrap_err(); - assert!( - matches!( - err, - ApiError::Validation { .. } | ApiError::Unsupported(_) | ApiError::UnknownId(_) - ), - "{err:?}" - ); - // `a` was NOT transformed (all-or-nothing): its location is unchanged. - let Some(EntityType::Point(pa)) = host.document().get_entity(obj_to_handle(a)) else { - panic!("point a not found") - }; - assert!( - (pa.location.x - 0.0).abs() < 1e-9, - "a moved despite locked batch member" - ); - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected TransformMany" - ); - } - - // ── Phase 2b-a: annotations (Text/MText) ───────────────────────────────── - - #[test] - fn phase2b_text_mtext_create_content_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create a TEXT, read its content, set it, verify. - let text = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "hello".into(), - insertion_point: [1.0, 2.0, 0.0], - height: 2.5, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetTextContent { id: text }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::TextContent(s) => assert_eq!(s, "hello"), - other => panic!("expected content, got {other:?}"), - } - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetTextContent { - id: text, - value: "world".into(), - }), - ) - .unwrap(); - let Some(EntityType::Text(t)) = host.document().get_entity(obj_to_handle(text)) else { - panic!("text not found") - }; - assert_eq!(t.value, "world"); - - // SetTextContent on a non-text entity is Unsupported. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetTextContent { - id: line, - value: "x".into() - }) - ) - .is_err()); - - // MTEXT create + content + transform (insertion point moves). - let mtext = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateMText(ocs_doc_api::ops::MTextSpec { - value: "multi\nline".into(), - insertion_point: [5.0, 5.0, 0.0], - height: 3.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetTextContent { id: mtext }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::TextContent(s) => assert_eq!(s, "multi\nline"), - other => panic!("expected content, got {other:?}"), - } - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: mtext, - placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let Some(EntityType::MText(t)) = host.document().get_entity(obj_to_handle(mtext)) else { - panic!("mtext not found") - }; - assert!((t.insertion_point.x - 15.0).abs() < 1e-6); - } - - // ── Outstanding methods: loft + bulge-arc profiles ─────────────────────── - - #[test] - fn loft_two_profiles_produces_solid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Two circles at different Z (profiles for loft). - let c1 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 0.0], - radius: 5.0, - })), - ) - .unwrap(), - ); - let c2 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 10.0], - radius: 2.0, - })), - ) - .unwrap(), - ); - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Loft { - profiles: vec![c1, c2], - }), - ); - let id = new_id(&result.expect("loft must preserve the sections' elevations")); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id }, Query::GetBounds { id }]), - ) - .unwrap(); - let QueryResult::Volume(volume) = receipt.query_results[0] else { - panic!("volume") - }; - let expected = std::f64::consts::PI * 10.0 * 39.0 / 3.0; - assert!( - (volume - expected).abs() < expected * 0.05, - "volume {volume}" - ); - let QueryResult::Bounds(bounds) = receipt.query_results[1] else { - panic!("bounds") - }; - assert!((bounds.min[2]).abs() < 1e-4 && (bounds.max[2] - 10.0).abs() < 1e-4); - } - - #[test] - fn bulge_arc_polyline_profile_converts_to_arc() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A polyline with a bulge (arc) segment: square with one curved side. - let poly = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], - closed: false, - })), - ) - .unwrap(), - ); - // Add a bulge to the first segment by editing the vertex. - let handle = obj_to_handle(poly); - let Some(EntityType::LwPolyline(mut pl)) = host.document().get_entity(handle).cloned() - else { - panic!("polyline not found") - }; - pl.vertices[0].bulge = 1.0; // 90-degree arc (tan(π/2/4) = 1) - host.scene_mut().update_entity(EntityType::LwPolyline(pl)); - - // profile_curves now converts the bulge to a Curve::Arc (not Unsupported). - let curves = host - .profile_curves(poly) - .expect("bulge profile must convert"); - // Open polyline, 3 vertices -> 2 segments: arc (bulged first), line. - assert_eq!(curves.len(), 2, "2 segments (arc + line)"); - assert!( - matches!(curves[0], cadkernel::geom2d::Curve::Arc(_)), - "first segment is an arc" - ); - assert!( - matches!(curves[1], cadkernel::geom2d::Curve::Line(_)), - "second is a line" - ); - // bulge=1 -> included angle θ = 4·atan(1) = π (a semicircle); radius = chord/2 = 5. - if let cadkernel::geom2d::Curve::Arc(arc) = &curves[0] { - assert!( - (arc.radius - 5.0).abs() < 0.01, - "arc radius {} (semicircle)", - arc.radius - ); - } - - // Negative (CW) bulge: the center must be on the CW side (mirror of CCW). - // A single 90° bulge segment start=(0,0), end=(1,0), bulge=-0.4142 (~90° CW): - // correct center is BELOW the chord; the buggy double-sign put it above. - let neg = convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], -0.4142) - .expect("negative bulge arc"); - if let cadkernel::geom2d::Curve::Arc(arc) = &neg { - // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. - assert!( - (arc.radius - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01, - "cw arc radius {}", - arc.radius - ); - assert!( - arc.centre[1] < 0.0, - "CW arc center y must be below the chord, got {:?}", - arc.centre - ); - } else { - panic!("expected an arc for negative bulge"); - } - // And the CCW mirror: center above. - let pos = - convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142).expect("positive bulge arc"); - if let cadkernel::geom2d::Curve::Arc(arc) = &pos { - assert!( - arc.centre[1] > 0.0, - "CCW arc center y must be above the chord, got {:?}", - arc.centre - ); - } - } - - // ── Phase 2c-ii: dimension sub-types (radius/diameter/angular) ────────── - - #[test] - fn phase2cii_dimension_radius_diameter_angular() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Radius: center (0,0,0), point on circle (4,0,0) -> radius 4. - let rad = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionRadius( - ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [4.0, 0.0, 0.0], - }, - )), - ) - .unwrap(), - ); - // Diameter: chord points (0,0,0) and (6,0,0) -> diameter 6. - let dia = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionDiameter( - ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [6.0, 0.0, 0.0], - }, - )), - ) - .unwrap(), - ); - // Angular: vertex (0,0,0), leg points (10,0,0) and (0,10,0) -> 90 degrees. - let ang = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionAngular( - ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [10.0, 0.0, 0.0], - second_point: [0.0, 10.0, 0.0], - arc_location: [5.0, 5.0, 0.0], - }, - )), - ) - .unwrap(), - ); - - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetDimensionMeasurement { id: rad }, - Query::GetDimensionMeasurement { id: dia }, - Query::GetDimensionMeasurement { id: ang }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::DimensionMeasurement(v) => assert!((*v - 4.0).abs() < 1e-4, "radius {v}"), - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::DimensionMeasurement(v) => { - assert!((*v - 6.0).abs() < 1e-4, "diameter {v}") - } - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[2] { - QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"), - other => panic!("expected measurement, got {other:?}"), - } - } - - // ── Phase 3-ii: typed AttributeDefinition create ────────────────────────── - - #[test] - fn phase3ii_create_attribute_definition() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let attdef = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateAttributeDefinition( - ocs_doc_api::ops::AttributeDefinitionSpec { - tag: "DOOR_NO".into(), - prompt: "Door number?".into(), - default_value: "D-1".into(), - insertion_point: [5.0, 5.0, 0.0], - height: 2.5, - rotation: 0.0, - }, - )), - ) - .unwrap(), - ); - let handle = obj_to_handle(attdef); - let Some(EntityType::AttributeDefinition(a)) = host.document().get_entity(handle) else { - panic!("attdef not found") - }; - assert_eq!(a.tag, "DOOR_NO"); - assert_eq!(a.default_value, "D-1"); - assert!((a.height - 2.5).abs() < 1e-9); - // GetEntity reports the AttributeDefinition kind. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntity { id: attdef }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "AttributeDefinition"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Phase 5-ii: typed Table create ─────────────────────────────────────── - - #[test] - fn phase5ii_create_table_from_grid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let data = vec![ - vec!["Name".to_string(), "Value".to_string()], - vec!["Length".to_string(), "100.0".to_string()], - vec!["Width".to_string(), "50.0".to_string()], - ]; - let table = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [10.0, 10.0, 0.0], - data: data.clone(), - })), - ) - .unwrap(), - ); - let handle = obj_to_handle(table); - let Some(EntityType::Table(t)) = host.document().get_entity(handle) else { - panic!("table not found") - }; - assert_eq!(t.rows.len(), 3); - assert_eq!(t.columns.len(), 2); - // Non-rectangular grid is a validation error. - let bad = vec![ - vec!["a".to_string()], - vec!["b".to_string(), "c".to_string()], - ]; - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [0.0; 3], - data: bad - })) - ) - .is_err()); - // GetEntity reports kind Table. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntity { id: table }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Table"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Outstanding method: accurate volume/centroid (fine tessellation) ───── - - #[test] - fn accurate_volume_centroid_sphere_and_cube() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Sphere r=5: analytic volume = 4/3 * pi * 125 ≈ 523.599; centroid at centre. - let ball = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Sphere { - centre: [10.0, 20.0, 30.0], - radius: 5.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetVolume { id: ball }, - Query::GetCentroid { id: ball }, - ]), - ) - .unwrap(); - let analytic = 4.0 / 3.0 * std::f64::consts::PI * 125.0; - match &receipt.query_results[0] { - QueryResult::Volume(v) => { - let err = ((*v - analytic) / analytic).abs(); - assert!( - err < 0.005, - "sphere volume {v} vs analytic {analytic} (err {err:.4})" - ); - } - other => panic!("expected volume, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Centroid(c) => { - assert!( - (c[0] - 10.0).abs() < 0.05 - && (c[1] - 20.0).abs() < 0.05 - && (c[2] - 30.0).abs() < 0.05, - "sphere centroid {c:?}" - ); - } - other => panic!("expected centroid, got {other:?}"), - } - // Cube 10^3 is exact (planar faces: divergence is exact regardless of LOD). - let cube = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id: cube }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Volume(v) => assert!((*v - 1000.0).abs() < 1.0, "cube volume {v}"), - other => panic!("expected volume, got {other:?}"), - } - } - - // ── Phase 2c-iii: 2-line angular dimension ──────────────────────────────── - - #[test] - fn phase2ciii_dimension_angular2ln() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Angle between line (0,0,0)->(10,0,0) and (0,0,0)->(0,10,0) = 90 degrees. - let ang = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionAngular2Ln( - ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [10.0, 0.0, 0.0], - second_point: [0.0, 10.0, 0.0], - arc_location: [5.0, 5.0, 0.0], - }, - )), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetDimensionMeasurement { id: ang }, - Query::GetEntity { id: ang }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"), - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Review-fix regression tests (Loft cap, CreateMany validation, Ellipse - // rotation, raster path validation) ───────────────────────────────────── - - #[test] - fn fix_loft_over_cap_is_rejected() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; - let profiles: Vec = (0..over).map(|i| ObjectId::from_u64(i as u64)).collect(); - let err = - dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles })).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - } - - #[test] - fn fix_create_many_invalid_curve_is_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let rev_before = host.scene().geometry_epoch; - // A valid point + an invalid ellipse (ratio > 1) in one CreateMany batch. - let specs = vec![ - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { layer: None, - centre: [0.0; 3], - major_axis: [1.0; 3], - ratio: 2.0, - start: 0.0, - end: std::f64::consts::TAU, - }), - ]; - let err = - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - // All-or-nothing: NO entity was created (the point would have been added first). - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected CreateMany" - ); - } - - #[test] - fn partial_ellipse_rotation_preserves_parameters() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Rotating the major axis already rotates the ellipse; its parameters stay fixed. - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - centre: [0.0; 3], - major_axis: [4.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::FRAC_PI_2, - })), - ) - .unwrap(), - ); - let rot90 = ocs_doc_api::PlacementSpec { - x_axis: [0.0, 1.0, 0.0], - y_axis: [-1.0, 0.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0; 3], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ell, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { - panic!("ellipse not found") - }; - let half_pi = std::f64::consts::FRAC_PI_2; - assert!(e.start_parameter.abs() < 1e-6); - assert!((e.end_parameter - half_pi).abs() < 1e-6); - // major_axis rotated from +X to +Y. - assert!( - (e.major_axis.y - 4.0).abs() < 1e-6, - "major_axis {:?}", - e.major_axis - ); - } - - #[test] - fn fix_raster_image_path_validation() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let mk = |path: &str| { - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: path.into(), - insertion_point: [0.0; 3], - u_vector: [0.5, 0.0, 0.0], - v_vector: [0.0, 0.5, 0.0], - size: [10.0, 10.0], - }) - }; - // Valid path succeeds. - assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/photo.png"))).is_ok()); - // UNC path rejected. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(mk("\\\\server\\share\\x.png")) - ) - .is_err()); - // Parent-traversal rejected. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(mk("C:/img/../../etc/passwd.png")) - ) - .is_err()); - // Non-image extension rejected. - assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/evil.exe"))).is_err()); - // Empty path rejected. - assert!(dispatch(&mut host, DocApiEnvelope::op(mk(""))).is_err()); - } - - // ── Read-mostly family bounds (Leader/Mesh/Face3D/MLine/Helix) ────────── - - #[test] - fn read_mostly_family_bounds_leader_and_mesh() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Leader with vertices at (0,0,0),(5,5,0),(10,0,0) -> bounds x[0,10] y[0,5]. - let leader = acadrust::entities::Leader { - vertices: vec![ - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - acadrust::types::Vector3::new(5.0, 5.0, 0.0), - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - ], - ..Default::default() - }; - let leader_h = host - .document_mut() - .add_entity(EntityType::Leader(leader)) - .unwrap(); - // Mesh with vertices in a unit cube at (2..3, 2..3, 0). - let mesh = acadrust::entities::Mesh { - vertices: vec![ - acadrust::types::Vector3::new(2.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 3.0, 0.0), - acadrust::types::Vector3::new(2.0, 3.0, 0.0), - ], - ..Default::default() - }; - let mesh_h = host - .document_mut() - .add_entity(EntityType::Mesh(mesh)) - .unwrap(); - - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetBounds { - id: handle_to_obj(leader_h), - }, - Query::GetBounds { - id: handle_to_obj(mesh_h), - }, - Query::GetEntity { - id: handle_to_obj(leader_h), - }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - match &receipt.query_results[2] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Leader"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Phase 5 remaining: typed raster image create ───────────────────────── - - #[test] - fn phase5_create_raster_image_registers_definition() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let img = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateRasterImage( - ocs_doc_api::ops::RasterImageSpec { - file_path: "C:/img/photo.png".into(), - insertion_point: [10.0, 20.0, 0.0], - u_vector: [0.5, 0.0, 0.0], - v_vector: [0.0, 0.5, 0.0], - size: [100.0, 50.0], - }, - )), - ) - .unwrap(), - ); - let handle = obj_to_handle(img); - let Some(EntityType::RasterImage(i)) = host.document().get_entity(handle) else { - panic!("image not found") - }; - assert_eq!(i.file_path, "C:/img/photo.png"); - // The host auto-registered an ImageDefinition (definition_handle set). - assert!( - i.definition_handle.is_some(), - "ImageDefinition was auto-registered" - ); - // Bounds = insertion + u*100 + v*50 = (60, 45) (4-corner bracket). - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: img }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - } - - // ── Phase 4 remaining: set_view + viewport-view query ──────────────────── - - #[test] - fn phase4_set_view_and_view_query() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let vp = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateViewport(ocs_doc_api::ops::ViewportSpec { - center: [50.0, 50.0, 0.0], - width: 40.0, - height: 30.0, - view_target: [0.0, 0.0, 0.0], - view_height: 100.0, - })), - ) - .unwrap(), - ); - - // set_view retargets + re-zooms in place. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetViewportView { - id: vp, - view_target: [20.0, 30.0, 0.0], - view_height: 50.0, - }), - ) - .unwrap(); - let Some(EntityType::Viewport(v)) = host.document().get_entity(obj_to_handle(vp)) else { - panic!("viewport not found") - }; - assert!((v.view_target.x - 20.0).abs() < 1e-6 && (v.view_height - 50.0).abs() < 1e-6); - - // viewport_view query reads it back. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetViewportView { id: vp }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::ViewportView { target, height } => { - assert!((target[0] - 20.0).abs() < 1e-6 && (*height - 50.0).abs() < 1e-6); - } - other => panic!("expected viewport view, got {other:?}"), - } - - // set_view on a non-viewport is Unsupported. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetViewportView { - id: line, - view_target: [0.0; 3], - view_height: 1.0 - }) - ) - .is_err()); - } - - // ── Phase 3 remaining: attributes + nested block traversal ────────────── - - #[test] - fn phase3_attributes_and_block_traversal() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A block "Door" containing a line; insert it, then set/read attributes. - host.document_mut() - .block_records - .add(acadrust::tables::BlockRecord::new("Door")) - .unwrap(); - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [10.0; 3], - })), - ) - .unwrap(), - ); - host.document_mut() - .block_records - .get_mut("Door") - .unwrap() - .entity_handles - .push(obj_to_handle(line)); - - let ins = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec { - block_name: "Door".into(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - - // set_attribute adds a new attribute; get_attributes reads it back. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetAttribute { - id: ins, - tag: "HANDLE".into(), - value: "A-1".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Attributes(attrs) => { - assert_eq!(attrs.len(), 1); - assert_eq!(attrs[0], ("HANDLE".to_string(), "A-1".to_string())); - } - other => panic!("expected attributes, got {other:?}"), - } - // set_attribute on an existing tag updates it. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetAttribute { - id: ins, - tag: "HANDLE".into(), - value: "A-2".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Attributes(attrs) => assert_eq!(attrs[0].1, "A-2"), - other => panic!("expected attributes, got {other:?}"), - } - - // Nested traversal: GetBlockEntities("Door") returns the line. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBlockEntities { - block_name: "Door".into(), - }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::BlockEntities(v) => { - assert_eq!(v.len(), 1); - assert_eq!(v[0].kind, "Line"); - } - other => panic!("expected block entities, got {other:?}"), - } - // Unknown block -> validation error. - assert!(dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBlockEntities { - block_name: "Nope".into() - }]) - ) - .is_err()); - } - - // ── Phase 2b-c: dimension ───────────────────────────────────────────────── - - #[test] - fn phase2c_dimension_linear_create_measurement_bounds() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Linear dimension from (0,0,0) to (30,0,0) with the line at (0,5,0). - let dim = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionLinear( - ocs_doc_api::ops::DimensionSpec { - first_point: [0.0, 0.0, 0.0], - second_point: [30.0, 0.0, 0.0], - definition_point: [0.0, 5.0, 0.0], - }, - )), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetDimensionMeasurement { id: dim }, - Query::GetEntity { id: dim }, - Query::GetBounds { id: dim }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::DimensionMeasurement(v) => { - assert!((*v - 30.0).abs() < 1e-6, "measurement {v}") - } - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[2] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 30.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - } - - // ── Phase 2b-b: hatch ──────────────────────────────────────────────────── - - #[test] - fn phase2b_hatch_create_boundary_bounds_delete() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Solid hatch over a unit square boundary. - let sq = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; - let hatch = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: sq.clone(), - solid: true, - })), - ) - .unwrap(), - ); - - // Boundary round-trips (one loop, 4 vertices). - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetHatchBoundary { id: hatch }, - Query::GetBounds { id: hatch }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::HatchBoundary(loops) => { - assert_eq!(loops.len(), 1); - assert_eq!(loops[0].len(), 4); - assert!( - (loops[0][0][0] - 0.0).abs() < 1e-9 && (loops[0][2][0] - 10.0).abs() < 1e-9 - ); - } - other => panic!("expected boundary, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[1] - 10.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - - // Boundary with < 3 points is a validation error. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: vec![[0.0, 0.0], [1.0, 1.0]], - solid: true - })) - ) - .is_err()); - - // Generic delete works. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Delete { id: hatch }), - ) - .unwrap(); - assert!(host.document().get_entity(obj_to_handle(hatch)).is_none()); - } - - // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── - - #[test] - fn phase5_media_entities_read_kind_and_bounds() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Insert a RasterImage directly (read-mostly: DocApi reads kind + bounds). - let img = { - use acadrust::entities::RasterImage; - use acadrust::types::{Vector2, Vector3}; - let img = RasterImage { - insertion_point: Vector3::new(10.0, 20.0, 0.0), - u_vector: Vector3::new(0.5, 0.0, 0.0), // 0.5 world-units/pixel in X - v_vector: Vector3::new(0.0, 0.5, 0.0), - size: Vector2::new(100.0, 50.0), - ..Default::default() - }; - host.document_mut() - .add_entity(EntityType::RasterImage(img)) - .unwrap() - }; - let img_id = handle_to_obj(img); - // GetEntity reports kind "RasterImage"; bounds = insertion + u*100 + v*50 = (60, 45). - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: img_id }, - Query::GetBounds { id: img_id }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "RasterImage"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - // Generic delete works on media entities (read-mostly, but deletable). - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Delete { id: img_id }), - ) - .unwrap(); - assert!(host.document().get_entity(img).is_none()); - } - - // ── Phase 3: containers (block references) ─────────────────────────────── - - #[test] - fn phase3_create_insert_and_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Register a block record so the insert can reference it. - host.document_mut() - .block_records - .add(acadrust::tables::BlockRecord::new("MyBlock")) - .unwrap(); - - // Insert the block at (10,20,0) scale 2, rotation 0. - let ins = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec { - block_name: "MyBlock".into(), - insert_point: [10.0, 20.0, 0.0], - scale: 2.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let handle = obj_to_handle(ins); - let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { - panic!("insert not found") - }; - assert_eq!(i.block_name, "MyBlock"); - assert!((i.insert_point.x - 10.0).abs() < 1e-9 && (i.x_scale() - 2.0).abs() < 1e-9); - - // Transform the insert by +5 in X. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ins, - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { - panic!("insert not found") - }; - assert!((i.insert_point.x - 15.0).abs() < 1e-6); - - // Inserting a non-existent block is a Validation error, no entity created. - let before = host.scene().geometry_epoch; - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec { - block_name: "NoSuchBlock".into(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - })), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - assert_eq!( - host.scene().geometry_epoch, - before, - "no entity on unknown block" - ); - } - - #[test] - fn doc_api_tab_mismatch_is_rejected() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A request naming a DIFFERENT tab than the bound one is rejected. - let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [0.0; 3], - })); - let bytes = bincode::serialize(&env).unwrap(); - let wrong_tab = host.tab_id().wrapping_add(999); - let err = execute_doc_api(&mut host, wrong_tab, &bytes).unwrap_err(); - assert!(err.contains("tab mismatch"), "{err}"); - } - - #[test] - fn doc_api_unknown_id_surfaces_structured_error() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let ghost = ObjectId::from_u64(0xDEAD); - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Delete { id: ghost }), - ) - .unwrap_err(); - assert!( - matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), - "{err:?}" - ); - } - - // ── Roundtrip tests: create -> read back via queries -> assert geometry ── - - use ocs_doc_api::ops::{Curve2Spec, PlacementSpec}; - - /// Create an entity via DocApi, then read it back via GetEntity/GetBounds and - /// assert the geometry round-trips with the expected kind + bounds. - #[test] - fn roundtrip_2d_entities_line_circle_point_polyline() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Line [0,0,0]-[10,0,0]: kind + bounds round-trip. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0, 0.0, 0.0], - end: [10.0, 0.0, 0.0], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: line }, - Query::GetBounds { id: line }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Line"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - - // Circle centre (5,5,0) r=3: bounds = centre ± radius in XY. - let circle = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [5.0, 5.0, 0.0], - radius: 3.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - - // Point at (1,2,3): degenerate bounds. - let point = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [1.0, 2.0, 3.0], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: point }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]), - other => panic!("expected bounds, got {other:?}"), - } - - // Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0. - let poly = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1.0, 0.0], - [0.0, 1.0, 0.0], - ], - closed: true, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - } - - /// Geometric methods round-trip: transform moves a solid (bounds shift); - /// union/subtract produce the expected mass/volume relationships. - #[test] - fn roundtrip_geometric_methods_transform_and_booleans() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Two overlapping boxes [0,10]^3 and [5,15]^3. - let a = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [5.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - - // Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125. - let intersect_op = Operation::SolidBoolean { - op: BoolOp::Intersection, - a, - b, - erase_sources: true, - }; - let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"), - other => panic!("expected volume, got {other:?}"), - } - - // THEN transform the result by +100 in X (a real, non-identity move): its - // bounds must shift by exactly +100 in X while Y/Z stay at [5,10]. - let move_op = Operation::Transform { - id: inter, - placement: PlacementSpec::at([100.0, 0.0, 0.0]), - }; - dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(bb) => { - assert!( - (bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, - "{bb:?}" - ); - assert!( - (bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, - "{bb:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - } - - /// DWG roundtrip: create two intersected solids, write the document to a DWG - /// file, read it back, and assert the intersected solid survives with valid - /// ACIS data (the exact path `restore_solid_models` re-lifts on load). - #[test] - fn roundtrip_intersected_solids_through_dwg_file() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Two overlapping boxes -> intersect -> the result solid (125 vol). - let a = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [5.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let intersect_op = Operation::SolidBoolean { - op: BoolOp::Intersection, - a, - b, - erase_sources: true, - }; - let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); - - // Write the live document to a STABLE, inspectable DWG in the workspace - // target dir so it can be opened in a CAD viewer after the test. The file - // is kept (not deleted). If the stable path is momentarily locked (e.g. a - // concurrent test run or a viewer holding it), fall back to a process- - // unique path for the roundtrip so the test never flakes. - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); - std::fs::create_dir_all(&dir).expect("create target dir"); - let doc = host.document().clone(); - let stable = dir.join("doc_api_roundtrip_intersected.dwg"); - // Release any stale lock/handle on the stable file before rewriting. - let _ = std::fs::remove_file(&stable); - let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) { - Ok(()) => stable, - Err(_) => { - // Locked by another live process (e.g. a viewer) — write a unique - // file for the roundtrip instead of failing. - let alt = dir.join(format!( - "doc_api_roundtrip_intersected_{}.dwg", - std::process::id() - )); - acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed"); - alt - } - }; - assert!(path.exists(), "DWG was written"); - - // Read it back. - let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG"); - let reloaded = reader.read().expect("read DWG failed"); - - // The intersected solid must be present as a Solid3D with valid ACIS data, - // and re-lifting it must reproduce a body with the expected volume. - let handle = obj_to_handle(lens); - let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else { - panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG"); - }; - assert!(solid.has_acis_data(), "reloaded solid carries ACIS data"); - - // Re-lift through the same SAT->kernel path used on load and check volume. - let body = crate::scene::convert::solid3d_tess::kernel_body(solid) - .expect("re-lift reloaded solid failed"); - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0; - assert!( - (vol - 125.0).abs() < 1.0, - "reloaded intersection volume {vol}" - ); - - // The DWG is intentionally kept for inspection (see `path` above). - } - #[test] - fn doc_api_failed_inputs_leave_document_and_history_unchanged() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let count = app.tabs[0].scene.document.entity_count(); - let dirty = app.tabs[0].dirty; - let undo = app.tabs[0].history.undo_stack.len(); - let epoch = app.tabs[0].scene.geometry_epoch; - { - let mut host = HostSession::new(&mut app, 0); - let invalid_ops = vec![ - Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: f64::NAN, - }), - ]), - Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0; 3], - radius: -1.0, - }), - Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0; 3], [1.0; 3]], - closed: false, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "test".into(), - insertion_point: [0.0; 3], - height: f64::INFINITY, - rotation: 0.0, - }), - ]; - for op in invalid_ops { - assert!(dispatch(&mut host, DocApiEnvelope::op(op)).is_err()); - assert!(!host.scene().is_recording_undo()); - } - } - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - assert_eq!(app.tabs[0].scene.geometry_epoch, epoch); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo); - assert_eq!(app.tabs[0].dirty, dirty); - } - - #[test] - fn doc_api_bulk_creation_is_dirty_and_undoes_in_one_step() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let undo = app.tabs[0].history.undo_stack.len(); - let count = app.tabs[0].scene.document.entity_count(); - let ids = { - let mut host = HostSession::new(&mut app, 0); - let receipt = dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [1.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - }), - ])), - ) - .unwrap(); - receipt.outcome.unwrap().new_ids().to_vec() - }; - assert!(app.tabs[0].dirty); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo + 1); - assert_eq!(app.tabs[0].scene.document.entity_count(), count + 2); - app.undo_active_tab(); - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - for id in &ids { - assert!(!app.tabs[0] - .scene - .solid_models - .contains_key(&obj_to_handle(*id))); - } - app.redo_active_tab(); - for id in ids { - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - } - } - - #[test] - fn doc_api_text_scale_and_elevated_extrusion() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let text = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "label".into(), - insertion_point: [1.0, 2.0, 0.0], - height: 2.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let placement = PlacementSpec { - origin: [0.0, 0.0, 7.0], - x_axis: [2.0, 0.0, 0.0], - y_axis: [0.0, 2.0, 0.0], - z_axis: [0.0, 0.0, 2.0], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: text, - placement, - }), - ) - .unwrap(); - let EntityType::Text(text) = host.document().get_entity(obj_to_handle(text)).unwrap() - else { - panic!("text") - }; - assert!((text.height - 4.0).abs() < 1e-9); - assert!((text.insertion_point.z - 7.0).abs() < 1e-9); - let profile = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 7.0], - [2.0, 0.0, 7.0], - [2.0, 3.0, 7.0], - [0.0, 3.0, 7.0], - ], - closed: true, - })), - ) - .unwrap(), - ); - let solid = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::Extrude { - profile, - direction: [0.0, 0.0, 5.0], - }), - ) - .unwrap(), - ); - let bounds = host.bounds(solid).unwrap(); - assert!( - (bounds.min[2] - 7.0).abs() < 1e-6 && (bounds.max[2] - 12.0).abs() < 1e-6, - "{bounds:?}" - ); - assert!((host.volume(solid).unwrap() - 30.0).abs() < 1e-6); - } - - #[test] - fn doc_api_envelope_guards_and_query_budget_reset() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - })), - ) - .unwrap(), - ); - let mut env = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - env.version += 1; - assert!(matches!( - dispatch(&mut host, env), - Err(ApiError::Unsupported(_)) - )); - let valid = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - let mut bytes = bincode::serialize(&valid).unwrap(); - bytes.push(0); - let tab = host.tab_id(); - assert!(execute_doc_api(&mut host, tab, &bytes).is_err()); - let bytes = bincode::serialize(&valid).unwrap(); - assert!(execute_doc_api(&mut host, tab + 1, &bytes).is_err()); - host.scene_mut().doc_api_cold_tess_used = 32; - let receipt = dispatch(&mut host, valid).unwrap(); - assert!( - matches!(receipt.query_results[0], QueryResult::Volume(v) if (v - 8.0).abs() < 1e-9) - ); - assert_eq!(host.scene().doc_api_cold_tess_used, 1); - } - #[test] - fn doc_api_plugin_wire_dispatch_and_image_undo() { - use ocs_plugin_api::ipc::{ - protocol::{PluginRequest, PluginResponse}, - server::handle_plugin_request, - }; - let mut app = OpenCADStudio::new_for_test(); - let objects = app.tabs[0].scene.document.objects.len(); - let id = { - let mut host = HostSession::new(&mut app, 0); - let env = DocApiEnvelope::op(Operation::CreateRasterImage( - ocs_doc_api::ops::RasterImageSpec { - file_path: "image.png".into(), - insertion_point: [0.0; 3], - u_vector: [1.0, 0.0, 0.0], - v_vector: [0.0, 1.0, 0.0], - size: [2.0; 2], - }, - )); - let request = PluginRequest::DocApiRequest { - tab_id: host.tab_id(), - bytes: bincode::serialize(&env).unwrap(), - }; - let request = bincode::deserialize(&bincode::serialize(&request).unwrap()).unwrap(); - let response = handle_plugin_request(&mut host, request, &mut |_| {}); - let response: PluginResponse = - bincode::deserialize(&bincode::serialize(&response).unwrap()).unwrap(); - let PluginResponse::DocApiResponse { bytes } = response else { - panic!("unexpected response") - }; - let result: ApiResult = bincode::deserialize(&bytes).unwrap(); - new_id(&result.unwrap()) - }; - assert!(app.tabs[0].scene.document.objects.len() > objects); - app.undo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_none()); - assert_eq!(app.tabs[0].scene.document.objects.len(), objects); - app.redo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - assert!(app.tabs[0].scene.document.objects.len() > objects); - } - - #[test] - fn doc_api_layer_crud_roundtrip_and_entity_assignment() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create a line to assign to a layer later. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - - // List default layers — must include "0". - let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); - let QueryResult::Layers(list) = &receipt.query_results[0] else { - panic!("expected Layers result"); - }; - assert!(list.iter().any(|l| l.name == "0")); - - // Create a new layer. - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - info.flags = LayerFlags { - frozen: false, - locked: false, - frozen_in_new_viewport: false, - off: false, - }; - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info.clone()))).unwrap(); - - // Update the layer color. - let mut updated = info.clone(); - updated.color = Color::Index(2); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::UpdateLayer { - name: " Walls ".into(), - info: updated.clone(), - }), - ) - .unwrap(); - - // Assign the line to the new layer. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: line, - layer: "Walls".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntityLayer { id: line }]), - ) - .unwrap(); - assert!(matches!(&receipt.query_results[0], QueryResult::EntityLayer(name) if name == "Walls")); - - // Deleting a layer still in use fails. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::DeleteLayer { - name: "Walls".into(), - }), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - // Move the line back to "0", then delete the layer. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: line, - layer: "0".into(), - }), - ) - .unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::DeleteLayer { - name: " Walls ".into(), - }), - ) - .unwrap(); - let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); - let QueryResult::Layers(list) = &receipt.query_results[0] else { - panic!("expected Layers result"); - }; - assert!(!list.iter().any(|l| l.name == "Walls")); - } - - #[test] - fn doc_api_xdata_batch_rejects_a_locked_entity_before_mutating() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let line = |host: &mut HostSession<'_>| { - new_id(&dispatch( - host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: None, - })), - ).unwrap()) - }; - let first = line(&mut host); - let locked = line(&mut host); - - let mut layer = LayerInfo::new("Locked"); - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(layer.clone()))).unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: locked, - layer: layer.name.clone(), - }), - ) - .unwrap(); - layer.flags.locked = true; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::UpdateLayer { - name: layer.name.clone(), - info: layer, - }), - ) - .unwrap(); - - let record = |application_name: &str| XDataRecord { - application_name: application_name.into(), - values: vec![XDataValue::Integer32(42)], - }; - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetXDataMany(vec![ - (first, "FIRST".into(), Some(record("FIRST"))), - (locked, "LOCKED".into(), Some(record("LOCKED"))), - ])), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(host.xdata(first, "FIRST").unwrap().is_none()); - } - - #[test] - fn doc_api_xrecords_keep_one_named_owner() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let spec = |name: &str| XRecordSpec { - name: name.into(), - cloning_flags: XRecordCloningFlags::NotApplicable, - entries: vec![XRecordEntry { - code: 1, - value: XRecordValue::String("value".into()), - }], - }; - - let first = new_id(&dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("FIRST"))), - ).unwrap()); - let first_handle = obj_to_handle(first); - let ObjectType::XRecord(first_record) = host.document().objects.get(&first_handle).unwrap() - else { - panic!("expected XRecord") - }; - let owner = first_record.owner; - assert!(matches!(host.document().objects.get(&owner), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, child)| name == "FIRST" && *child == first_handle))); - - let object_count = host.document().objects.len(); - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("first"))), - ), - Err(ApiError::Validation { .. }) - )); - assert_eq!(host.document().objects.len(), object_count); - - let second = new_id(&dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("SECOND"))), - ).unwrap()); - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetXRecord { - id: second, - spec: spec("FIRST"), - }), - ), - Err(ApiError::Validation { .. }) - )); - assert_eq!(host.xrecord(second).unwrap().unwrap().name, "SECOND"); - - let mut invalid = spec("INVALID"); - invalid.entries[0] = XRecordEntry { - code: 1, - value: XRecordValue::Int32(7), - }; - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(invalid)), - ), - Err(ApiError::Validation { .. }) - )); - } - - #[test] - fn doc_api_enumerate_entities_and_geometry_queries() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create entities on different layers. - let line_id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, - start: [1.0, 2.0, 3.0], - end: [4.0, 5.0, 6.0], - })), - ) - .unwrap(), - ); - let point_id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Point { layer: None, - position: [7.0, 8.0, 9.0], - })), - ) - .unwrap(), - ); - - // Create a layer and move the point to it. - let mut info = LayerInfo::new("Markers"); - info.color = Color::Index(3); - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info))).unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: point_id, - layer: "Markers".into(), - }), - ) - .unwrap(); - - // Enumerate all entities. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: None, - layer: None, - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(all) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert!(all.iter().any(|e| e.id == line_id && e.kind == "Line" && e.layer == "0")); - assert!(all.iter().any(|e| e.id == point_id && e.kind == "Point" && e.layer == "Markers")); - - // Filter by kind. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: Some("Line".into()), - layer: None, - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(lines) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].id, line_id); - - // Filter by layer. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: None, - layer: Some(" Markers ".into()), - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(markers) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert_eq!(markers.len(), 1); - assert_eq!(markers[0].id, point_id); - - // Geometry queries. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetPointPosition { id: point_id }, - Query::GetLineGeometry { id: line_id }, - ]), - ) - .unwrap(); - let QueryResult::PointPosition(pos) = &receipt.query_results[0] else { - panic!("expected PointPosition result"); - }; - assert!((pos[0] - 7.0).abs() < 1e-9); - assert!((pos[1] - 8.0).abs() < 1e-9); - assert!((pos[2] - 9.0).abs() < 1e-9); - let QueryResult::LineGeometry((start, end)) = &receipt.query_results[1] else { - panic!("expected LineGeometry result"); - }; - assert!((start[0] - 1.0).abs() < 1e-9); - assert!((end[0] - 4.0).abs() < 1e-9); - - // Wrong entity family is rejected. - let err = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetPointPosition { id: line_id }]), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Unsupported { .. })); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c2cd3e73..5582a774d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,8 +9,6 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; -#[cfg(not(target_arch = "wasm32"))] -mod doc_api; mod document; pub(crate) mod expr_eval; mod find_replace; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index c39129315..d60992822 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -16,8 +16,6 @@ pub(crate) struct HostSession<'a> { app: &'a mut OpenCADStudio, tab: usize, doc_store: Option>, - /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). - doc_api_pending: Option, } impl<'a> HostSession<'a> { @@ -26,7 +24,6 @@ impl<'a> HostSession<'a> { app, tab, doc_store: None, - doc_api_pending: None, } } @@ -245,39 +242,6 @@ impl<'a> HostSession<'a> { self.app.push_undo_snapshot(self.tab, label); } - // ── DocApi v2 accessors (used by `app::doc_api` backend) ─────────────── - - pub(crate) fn scene(&self) -> &crate::scene::Scene { - &self.app.tabs[self.tab].scene - } - - pub(crate) fn scene_mut(&mut self) -> &mut crate::scene::Scene { - &mut self.app.tabs[self.tab].scene - } - - /// DocApi `push_undo`: capture entity and document-structure changes for one op. - pub(crate) fn begin_doc_api_undo(&mut self, label: &str) { - self.doc_api_pending = self.app.begin_undo(self.tab, label, 1, false); - } - - pub(crate) fn cancel_doc_api_undo(&mut self) { - if self.doc_api_pending.take().is_some() { - self.scene_mut().take_undo_recording(); - } - } - - /// DocApi `finalize_op`: close the delta + republish the document view. - /// The underlying entity op (add/update/remove/register_solid_model) already - /// bumped `geometry_epoch`, so we do NOT bump again here — exactly one undo - /// step is recorded, and the epoch has advanced for this op. - pub(crate) fn commit_doc_api_undo(&mut self) { - self.set_dirty(); - if let Some(pending) = self.doc_api_pending.take() { - self.app.commit_undo_delta(self.tab, pending); - } - self.publish_document_view(); - } - pub fn set_dirty(&mut self) { self.app.tabs[self.tab].dirty = true; } @@ -396,10 +360,6 @@ impl HostApi for HostSession<'_> { fn close_document_view_v4(&mut self, tab_id: u64) { self.close_document_view_v4(tab_id) } - #[cfg(not(target_arch = "wasm32"))] - fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { - super::doc_api::execute_doc_api(self, tab_id, bytes) - } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) From ac8215c1b728c1be9316c96e5f0a784b230caa1f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:13:45 +0300 Subject: [PATCH 16/99] Center Draw popup beneath its title text --- src/ui/ribbon/draw_panel.rs | 16 ++++------------ src/ui/ribbon/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index d444d8f05..0f11f9ba8 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -16,7 +16,7 @@ use crate::t; use crate::ui::{icons, wrap_bar::PosReport}; const PANEL_ID: &str = "draw_extension"; -const GROUP_ID: &str = "draw_extension_group"; +const TITLE_ID: &str = "draw_extension_title"; const SCALE: f32 = 0.7; const CELL: f32 = 36.0 * SCALE; const GAP: f32 = 3.0 * SCALE; @@ -125,14 +125,6 @@ pub(super) fn owns_dropdown(id: &str) -> bool { .any(|tool| tool.command == id && !tool.options.is_empty()) } -pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> Element<'a, Message> { - if title == "Draw" { - PosReport::new(GROUP_ID, content).into() - } else { - content - } -} - pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { if title != "Draw" || TOOLS.is_empty() { return container(text(t!(title)).size(9).style(muted_text_style)) @@ -148,7 +140,7 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Ele PosReport::new( PANEL_ID, button( - row![text(t!(title)).size(9), arrow] + row![PosReport::new(TITLE_ID, text(t!(title)).size(9)), arrow] .spacing(4) .align_y(iced::Center), ) @@ -197,9 +189,9 @@ fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0)); let (_, x, anchor_top) = ribbon.dd_anchor(PANEL_ID, width, win.0); - let anchor = crate::ui::wrap_bar::dropdown_bounds(GROUP_ID); + let anchor = crate::ui::wrap_bar::dropdown_bounds(TITLE_ID); let left = anchor - .map_or(x, |b| b.x) + .map_or(x, |b| b.x + (b.width - width) / 2.0) .clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 7ffc5ec7b..e0f2b2c49 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1370,7 +1370,7 @@ fn render_group<'a>( r.push(e) }); - draw_panel::group_anchor(group.title, column![ + column![ tools_el, draw_panel::group_title(group.title, open_dd), ] @@ -1378,7 +1378,7 @@ fn render_group<'a>( .spacing(0) .padding([3u16, 4]) .height(Length::Fixed(TOOL_BAR_H)) - .into()) + .into() } /// The top-level command id of a ribbon item, if it has one. From 75cc49d629d3f099909913c4caa16bb7399ddfdc Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:27:59 +0300 Subject: [PATCH 17/99] Return from Draw submenus with Escape and place split tools last --- src/app/update/command.rs | 3 +++ src/ui/ribbon/draw_panel.rs | 22 +++++++++++----------- src/ui/ribbon/mod.rs | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 23df084a7..e82e3cc98 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -890,6 +890,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_command_escape(&mut self) -> Task { + if self.ribbon.escape_extension() { + return Task::none(); + } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. self.snap_override_popup = None; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 0f11f9ba8..8f985b9c9 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -15,7 +15,7 @@ use crate::modules::IconKind; use crate::t; use crate::ui::{icons, wrap_bar::PosReport}; -const PANEL_ID: &str = "draw_extension"; +pub(super) const PANEL_ID: &str = "draw_extension"; const TITLE_ID: &str = "draw_extension_title"; const SCALE: f32 = 0.7; const CELL: f32 = 36.0 * SCALE; @@ -54,16 +54,6 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/ray.svg"), options: &[], }, - Tool { - command: "MULTIPOINT", - label: "Multiple Points", - icon: include_bytes!("../../../assets/icons/multipoint.svg"), - options: &[ - ("POINT", "Single Point"), - ("MULTIPOINT", "Multiple Points"), - ("DDPTYPE", "Point Style"), - ], - }, Tool { command: "DIVIDE", label: "Divide", @@ -106,6 +96,16 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/donut.svg"), options: &[], }, + Tool { + command: "MULTIPOINT", + label: "Multiple Points", + icon: include_bytes!("../../../assets/icons/multipoint.svg"), + options: &[ + ("POINT", "Single Point"), + ("MULTIPOINT", "Multiple Points"), + ("DDPTYPE", "Point Style"), + ], + }, Tool { command: "REVCLOUD", label: "Revision Cloud", diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index e0f2b2c49..f0938716f 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -351,6 +351,21 @@ impl Ribbon { self.layer_filter.clear(); } + pub fn escape_extension(&mut self) -> bool { + let Some(id) = self.open_dropdown.as_deref() else { + return false; + }; + if !draw_panel::owns_dropdown(id) { + return false; + } + if id == draw_panel::PANEL_ID { + self.close_dropdown(); + } else { + self.open_dropdown = Some(draw_panel::PANEL_ID.to_string()); + } + true + } + /// Toggle the flyout of a collapsed ribbon panel (identified by its title). pub fn toggle_collapsed_panel(&mut self, id: &str) { if self.collapsed_open.as_deref() == Some(id) { From 09c1910f6af022b3a17df8f625d2fcbbc3b6ef2a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:30:48 +0300 Subject: [PATCH 18/99] Share centered extension panels with Modify tool contributions --- build.rs | 22 +++++++++++ src/ui/ribbon/draw_panel.rs | 70 +++++++++++++++++++++++------------ src/ui/ribbon/mod.rs | 9 +++-- src/ui/ribbon/modify_panel.rs | 4 ++ 4 files changed, 78 insertions(+), 27 deletions(-) create mode 100644 src/ui/ribbon/modify_panel.rs diff --git a/build.rs b/build.rs index fd50c581c..85ce9861f 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,7 @@ use std::path::Path; fn main() { + generate_modify_tools(); let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); let app_version = if parts.len() == 3 && parts[0].len() == 4 @@ -81,3 +82,24 @@ fn main() { } } } + +fn generate_modify_tools() { + let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let directory = root.join("src/ui/ribbon/modify_tools"); + println!("cargo:rerun-if-changed=src/ui/ribbon/modify_tools"); + let mut paths: Vec<_> = match std::fs::read_dir(&directory) { + Ok(entries) => entries.map(|entry| entry.expect("Modify tool entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")).collect(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => panic!("Cannot read Modify tools: {error}"), + }; + paths.sort(); + let mut source = String::from("pub(super) const TOOLS: &[Tool] = &[\n"); + for path in paths { + println!("cargo:rerun-if-changed={}", path.display()); + source.push_str(&format!("include!({:?}),\n", path.to_string_lossy())); + } + source.push_str("];\n"); + let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + std::fs::write(output.join("modify_panel_tools.rs"), source).expect("Write Modify tool registry"); +} diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 8f985b9c9..a4d30522a 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -22,11 +22,11 @@ const CELL: f32 = 36.0 * SCALE; const GAP: f32 = 3.0 * SCALE; const OPTION_HEIGHT: f32 = 30.0 * SCALE; -struct Tool { - command: &'static str, - label: &'static str, - icon: &'static [u8], - options: &'static [(&'static str, &'static str)], +pub(super) struct Tool { + pub command: &'static str, + pub label: &'static str, + pub icon: &'static [u8], + pub options: &'static [(&'static str, &'static str)], } const TOOLS: &[Tool] = &[ @@ -118,43 +118,63 @@ const TOOLS: &[Tool] = &[ }, ]; +#[derive(Clone, Copy)] +struct Panel { + id: &'static str, + title_id: &'static str, + title: &'static str, + tools: &'static [Tool], +} + +const PANELS: &[Panel] = &[ + Panel { id: PANEL_ID, title_id: TITLE_ID, title: "Draw", tools: TOOLS }, + Panel { id: "modify_extension", title_id: "modify_extension_title", title: "Modify", tools: super::modify_panel::TOOLS }, +]; + +fn panel_for_dropdown(id: &str) -> Option { + PANELS.iter().copied().find(|panel| { + panel.id == id || panel.tools.iter().any(|tool| tool.command == id && !tool.options.is_empty()) + }) +} + pub(super) fn owns_dropdown(id: &str) -> bool { - id == PANEL_ID - || TOOLS - .iter() - .any(|tool| tool.command == id && !tool.options.is_empty()) + panel_for_dropdown(id).is_some() +} + +pub(super) fn parent_panel(id: &str) -> Option<&'static str> { + panel_for_dropdown(id).map(|panel| panel.id) } pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { - if title != "Draw" || TOOLS.is_empty() { + let Some(panel) = PANELS.iter().find(|panel| panel.title == title && !panel.tools.is_empty()) else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); - } - let expanded = open.as_deref().is_some_and(owns_dropdown); + }; + let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id); let arrow = if expanded { icons::themed_arrow_up(7.0) } else { icons::themed_arrow_down(7.0) }; PosReport::new( - PANEL_ID, + panel.id, button( - row![PosReport::new(TITLE_ID, text(t!(title)).size(9)), arrow] + row![PosReport::new(panel.title_id, text(t!(title)).size(9)), arrow] .spacing(4) .align_y(iced::Center), ) - .on_press(Message::ToggleRibbonDropdown(PANEL_ID.to_string())) + .on_press(Message::ToggleRibbonDropdown(panel.id.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) .padding([1, 4]), ) .into() } -fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { +fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'static, Message> { let face = button(make_icon(IconKind::Svg(tool.icon), 23.0 * SCALE)) .on_press(Message::DropdownSelectItem { - dropdown_id: PANEL_ID, + dropdown_id: panel_id, cmd: tool.command, }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) @@ -187,22 +207,26 @@ fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { } pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { + let panel = panel_for_dropdown(id).expect("registered ribbon extension"); + let tools = panel.tools; let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0)); - let (_, x, anchor_top) = ribbon.dd_anchor(PANEL_ID, width, win.0); - let anchor = crate::ui::wrap_bar::dropdown_bounds(TITLE_ID); + let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0); + let anchor = crate::ui::wrap_bar::dropdown_bounds(panel.title_id); let left = anchor .map_or(x, |b| b.x + (b.width - width) / 2.0) .clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); - let option_tool = TOOLS + let option_tool = tools .iter() .find(|tool| tool.command == id && !tool.options.is_empty()); let content_height = option_tool.map_or_else( - || TOOLS.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, + || tools.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, |tool| tool.options.len() as f32 * OPTION_HEIGHT, ); + let ordered: Vec<&Tool> = tools.iter().filter(|tool| tool.options.is_empty()) + .chain(tools.iter().filter(|tool| !tool.options.is_empty())).collect(); let contents: Element<'static, Message> = if let Some(tool) = option_tool { column( tool.options @@ -224,13 +248,13 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .into() } else { column( - TOOLS + ordered .chunks(cols) .map(|tools| { row(tools .iter() .map(|tool| { - tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command)) + tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command), panel.id) }) .collect::>()) .spacing(GAP) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index f0938716f..feed284f9 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -22,6 +22,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; mod draw_panel; +mod modify_panel; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -355,13 +356,13 @@ impl Ribbon { let Some(id) = self.open_dropdown.as_deref() else { return false; }; - if !draw_panel::owns_dropdown(id) { + let Some(parent) = draw_panel::parent_panel(id) else { return false; - } - if id == draw_panel::PANEL_ID { + }; + if id == parent { self.close_dropdown(); } else { - self.open_dropdown = Some(draw_panel::PANEL_ID.to_string()); + self.open_dropdown = Some(parent.to_string()); } true } diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs new file mode 100644 index 000000000..d04410b12 --- /dev/null +++ b/src/ui/ribbon/modify_panel.rs @@ -0,0 +1,4 @@ +//! Independently registered commands in the Modify extension. +use super::draw_panel::Tool; + +include!(concat!(env!("OUT_DIR"), "/modify_panel_tools.rs")); From bf590d7d26728d0e6cc3c85bbf4565daed87e5eb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:32:37 +0300 Subject: [PATCH 19/99] Expose Set to ByLayer in Modify with interactive selection --- assets/icons/modify_setbylayer.svg | 1 + src/app/commands/styleprops.rs | 7 +++++++ src/ui/ribbon/modify_tools/01_setbylayer.rs | 6 ++++++ 3 files changed, 14 insertions(+) create mode 100644 assets/icons/modify_setbylayer.svg create mode 100644 src/ui/ribbon/modify_tools/01_setbylayer.rs diff --git a/assets/icons/modify_setbylayer.svg b/assets/icons/modify_setbylayer.svg new file mode 100644 index 000000000..9ce5827de --- /dev/null +++ b/assets/icons/modify_setbylayer.svg @@ -0,0 +1 @@ + diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index b99da19b4..372464252 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -646,6 +646,13 @@ impl OpenCADStudio { // Resets the selected entities' direct property overrides back to // ByLayer so they follow their layer again. "SETBYLAYER" => { + if self.tabs[i].scene.selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new("SETBYLAYER"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + return Some(self.finish_dispatch(cmd)); + } let handles: Vec<_> = self.tabs[i] .scene .selected_entities() diff --git a/src/ui/ribbon/modify_tools/01_setbylayer.rs b/src/ui/ribbon/modify_tools/01_setbylayer.rs new file mode 100644 index 000000000..953ee34ed --- /dev/null +++ b/src/ui/ribbon/modify_tools/01_setbylayer.rs @@ -0,0 +1,6 @@ +Tool { + command: "SETBYLAYER", + label: "Set to ByLayer", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_setbylayer.svg")), + options: &[], +} From 558e4d7cfd54fa4ac0731d49ed4d92e33ee3d09d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:32:56 +0300 Subject: [PATCH 20/99] Expose Lengthen in the Modify extension --- assets/icons/modify_lengthen.svg | 1 + src/ui/ribbon/modify_tools/02_lengthen.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_lengthen.svg create mode 100644 src/ui/ribbon/modify_tools/02_lengthen.rs diff --git a/assets/icons/modify_lengthen.svg b/assets/icons/modify_lengthen.svg new file mode 100644 index 000000000..834650c5d --- /dev/null +++ b/assets/icons/modify_lengthen.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/02_lengthen.rs b/src/ui/ribbon/modify_tools/02_lengthen.rs new file mode 100644 index 000000000..384429fb5 --- /dev/null +++ b/src/ui/ribbon/modify_tools/02_lengthen.rs @@ -0,0 +1,6 @@ +Tool { + command: "LENGTHEN", + label: "Lengthen", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_lengthen.svg")), + options: &[], +} From 4989f882f480c53739e68f03c0c72b5bc2e33452 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:32:57 +0300 Subject: [PATCH 21/99] Expose Edit Polyline in the Modify extension --- assets/icons/modify_pedit.svg | 1 + src/ui/ribbon/modify_tools/03_pedit.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_pedit.svg create mode 100644 src/ui/ribbon/modify_tools/03_pedit.rs diff --git a/assets/icons/modify_pedit.svg b/assets/icons/modify_pedit.svg new file mode 100644 index 000000000..ef1459050 --- /dev/null +++ b/assets/icons/modify_pedit.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/03_pedit.rs b/src/ui/ribbon/modify_tools/03_pedit.rs new file mode 100644 index 000000000..7847b06e9 --- /dev/null +++ b/src/ui/ribbon/modify_tools/03_pedit.rs @@ -0,0 +1,6 @@ +Tool { + command: "PEDIT", + label: "Edit Polyline", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_pedit.svg")), + options: &[], +} From d836d1df38bf18db9a459eccff2b15359f5400f1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:32:58 +0300 Subject: [PATCH 22/99] Expose Edit Spline in the Modify extension --- assets/icons/modify_splinedit.svg | 1 + src/ui/ribbon/modify_tools/04_splinedit.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_splinedit.svg create mode 100644 src/ui/ribbon/modify_tools/04_splinedit.rs diff --git a/assets/icons/modify_splinedit.svg b/assets/icons/modify_splinedit.svg new file mode 100644 index 000000000..977c90ddc --- /dev/null +++ b/assets/icons/modify_splinedit.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/04_splinedit.rs b/src/ui/ribbon/modify_tools/04_splinedit.rs new file mode 100644 index 000000000..435434b33 --- /dev/null +++ b/src/ui/ribbon/modify_tools/04_splinedit.rs @@ -0,0 +1,6 @@ +Tool { + command: "SPLINEDIT", + label: "Edit Spline", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_splinedit.svg")), + options: &[], +} From c816538237da839722d2fdc9d0aaad2772e5d8be Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:32:59 +0300 Subject: [PATCH 23/99] Expose Edit Hatch in the Modify extension --- assets/icons/modify_hatchedit.svg | 1 + src/ui/ribbon/modify_tools/05_hatchedit.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_hatchedit.svg create mode 100644 src/ui/ribbon/modify_tools/05_hatchedit.rs diff --git a/assets/icons/modify_hatchedit.svg b/assets/icons/modify_hatchedit.svg new file mode 100644 index 000000000..82ea41111 --- /dev/null +++ b/assets/icons/modify_hatchedit.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/05_hatchedit.rs b/src/ui/ribbon/modify_tools/05_hatchedit.rs new file mode 100644 index 000000000..477696833 --- /dev/null +++ b/src/ui/ribbon/modify_tools/05_hatchedit.rs @@ -0,0 +1,6 @@ +Tool { + command: "HATCHEDIT", + label: "Edit Hatch", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_hatchedit.svg")), + options: &[], +} From 0b619732a61ec52bcb9698e37d9ff46a4417a504 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:00 +0300 Subject: [PATCH 24/99] Expose Align in the Modify extension --- assets/icons/modify_align.svg | 1 + src/ui/ribbon/modify_tools/06_align.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_align.svg create mode 100644 src/ui/ribbon/modify_tools/06_align.rs diff --git a/assets/icons/modify_align.svg b/assets/icons/modify_align.svg new file mode 100644 index 000000000..3b661c84f --- /dev/null +++ b/assets/icons/modify_align.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/06_align.rs b/src/ui/ribbon/modify_tools/06_align.rs new file mode 100644 index 000000000..7f982c718 --- /dev/null +++ b/src/ui/ribbon/modify_tools/06_align.rs @@ -0,0 +1,6 @@ +Tool { + command: "ALIGN", + label: "Align", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_align.svg")), + options: &[], +} From d2d0cbc3d7f8579f4602dbc1f14742904372f50c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:01 +0300 Subject: [PATCH 25/99] Expose Break in the Modify extension --- assets/icons/modify_break.svg | 1 + src/ui/ribbon/modify_tools/07_break.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_break.svg create mode 100644 src/ui/ribbon/modify_tools/07_break.rs diff --git a/assets/icons/modify_break.svg b/assets/icons/modify_break.svg new file mode 100644 index 000000000..83c41029a --- /dev/null +++ b/assets/icons/modify_break.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/07_break.rs b/src/ui/ribbon/modify_tools/07_break.rs new file mode 100644 index 000000000..1f130b827 --- /dev/null +++ b/src/ui/ribbon/modify_tools/07_break.rs @@ -0,0 +1,6 @@ +Tool { + command: "BREAK", + label: "Break", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_break.svg")), + options: &[], +} From cd82deff3c2e583df89facfa73a0722a8e911aca Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:02 +0300 Subject: [PATCH 26/99] Expose Break at Point in the Modify extension --- assets/icons/modify_breakatpoint.svg | 1 + src/ui/ribbon/modify_tools/08_breakatpoint.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_breakatpoint.svg create mode 100644 src/ui/ribbon/modify_tools/08_breakatpoint.rs diff --git a/assets/icons/modify_breakatpoint.svg b/assets/icons/modify_breakatpoint.svg new file mode 100644 index 000000000..588d967f8 --- /dev/null +++ b/assets/icons/modify_breakatpoint.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/08_breakatpoint.rs b/src/ui/ribbon/modify_tools/08_breakatpoint.rs new file mode 100644 index 000000000..298b28025 --- /dev/null +++ b/src/ui/ribbon/modify_tools/08_breakatpoint.rs @@ -0,0 +1,6 @@ +Tool { + command: "BREAKATPOINT", + label: "Break at Point", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_breakatpoint.svg")), + options: &[], +} From f6d9b52ec5d0b4ba9d517c48677f75daf9bb9086 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:03 +0300 Subject: [PATCH 27/99] Expose Join in the Modify extension --- assets/icons/modify_join.svg | 1 + src/ui/ribbon/modify_tools/09_join.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_join.svg create mode 100644 src/ui/ribbon/modify_tools/09_join.rs diff --git a/assets/icons/modify_join.svg b/assets/icons/modify_join.svg new file mode 100644 index 000000000..687232f44 --- /dev/null +++ b/assets/icons/modify_join.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/09_join.rs b/src/ui/ribbon/modify_tools/09_join.rs new file mode 100644 index 000000000..65d447e95 --- /dev/null +++ b/src/ui/ribbon/modify_tools/09_join.rs @@ -0,0 +1,6 @@ +Tool { + command: "JOIN", + label: "Join", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_join.svg")), + options: &[], +} From 4a2ea35644663072f4aa53fd98c91bcec494c93d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:03 +0300 Subject: [PATCH 28/99] Expose Reverse in the Modify extension --- assets/icons/modify_reverse.svg | 1 + src/ui/ribbon/modify_tools/10_reverse.rs | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 assets/icons/modify_reverse.svg create mode 100644 src/ui/ribbon/modify_tools/10_reverse.rs diff --git a/assets/icons/modify_reverse.svg b/assets/icons/modify_reverse.svg new file mode 100644 index 000000000..04c802a8d --- /dev/null +++ b/assets/icons/modify_reverse.svg @@ -0,0 +1 @@ + diff --git a/src/ui/ribbon/modify_tools/10_reverse.rs b/src/ui/ribbon/modify_tools/10_reverse.rs new file mode 100644 index 000000000..7ce5c6d9e --- /dev/null +++ b/src/ui/ribbon/modify_tools/10_reverse.rs @@ -0,0 +1,6 @@ +Tool { + command: "REVERSE", + label: "Reverse", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_reverse.svg")), + options: &[], +} From 0af477544d2979afc0d602323508bf0500542b89 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:04 +0300 Subject: [PATCH 29/99] Expose Copy Nested Objects in the Modify extension --- assets/icons/modify_ncopy.svg | 1 + src/app/commands/blocks.rs | 7 +++++++ src/ui/ribbon/modify_tools/11_ncopy.rs | 6 ++++++ 3 files changed, 14 insertions(+) create mode 100644 assets/icons/modify_ncopy.svg create mode 100644 src/ui/ribbon/modify_tools/11_ncopy.rs diff --git a/assets/icons/modify_ncopy.svg b/assets/icons/modify_ncopy.svg new file mode 100644 index 000000000..a7ce9f0ba --- /dev/null +++ b/assets/icons/modify_ncopy.svg @@ -0,0 +1 @@ + diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index d020e42cc..50f4aedbc 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -659,6 +659,13 @@ impl OpenCADStudio { // every nested object is copied — the block is not exploded). "NCOPY" | "NCOPYALL" => { use crate::modules::draw::modify::explode::explode_entity; + if cmd == "NCOPY" && self.tabs[i].scene.selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new("NCOPY"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + return Some(self.finish_dispatch(cmd)); + } let inserts: Vec = self.tabs[i] .scene .selected_entities() diff --git a/src/ui/ribbon/modify_tools/11_ncopy.rs b/src/ui/ribbon/modify_tools/11_ncopy.rs new file mode 100644 index 000000000..e926ce540 --- /dev/null +++ b/src/ui/ribbon/modify_tools/11_ncopy.rs @@ -0,0 +1,6 @@ +Tool { + command: "NCOPY", + label: "Copy Nested Objects", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_ncopy.svg")), + options: &[], +} From e52965cfb0da220085ef61b6abaf7d9196ee7164 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:05 +0300 Subject: [PATCH 30/99] Expose Delete Duplicate Objects in the Modify extension --- assets/icons/modify_overkill.svg | 1 + src/app/commands/styleprops.rs | 11 +++++++++-- src/ui/ribbon/modify_tools/12_overkill.rs | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 assets/icons/modify_overkill.svg create mode 100644 src/ui/ribbon/modify_tools/12_overkill.rs diff --git a/assets/icons/modify_overkill.svg b/assets/icons/modify_overkill.svg new file mode 100644 index 000000000..3ddb7bfa8 --- /dev/null +++ b/assets/icons/modify_overkill.svg @@ -0,0 +1 @@ + diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index b99da19b4..80d607030 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -685,10 +685,17 @@ impl OpenCADStudio { // ── OVERKILL — delete duplicate (identical) objects ────────── // Removes objects that are identical in geometry AND properties to // another object (compared with the handle ignored). Operates on - // the current selection, or the whole drawing when nothing is + // the current selection, gathering objects first when nothing is // selected. Conservative: only exact duplicates are removed. "OVERKILL" => { use acadrust::Handle; + if self.tabs[i].scene.selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new("OVERKILL"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + return Some(self.finish_dispatch(cmd)); + } let selected: std::collections::HashSet = self.tabs[i] .scene .selected_entities() @@ -702,7 +709,7 @@ impl OpenCADStudio { .document .entities() .filter(|e| { - (selected.is_empty() || selected.contains(&e.common().handle.value())) + selected.contains(&e.common().handle.value()) && !self.tabs[i].scene.is_layer_locked(e.common().handle) }) .map(|e| { diff --git a/src/ui/ribbon/modify_tools/12_overkill.rs b/src/ui/ribbon/modify_tools/12_overkill.rs new file mode 100644 index 000000000..0c7f90e0e --- /dev/null +++ b/src/ui/ribbon/modify_tools/12_overkill.rs @@ -0,0 +1,6 @@ +Tool { + command: "OVERKILL", + label: "Delete Duplicate Objects", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_overkill.svg")), + options: &[], +} From c56ade3f2e523fb3eb576675d78590b9f3748552 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:06 +0300 Subject: [PATCH 31/99] Expose Draw Order in the Modify extension --- assets/icons/modify_draworder.svg | 1 + src/app/commands/mod.rs | 4 +++ src/app/commands/view.rs | 31 ++++++++++++++++++++++ src/ui/ribbon/modify_tools/13_draworder.rs | 6 +++++ 4 files changed, 42 insertions(+) create mode 100644 assets/icons/modify_draworder.svg create mode 100644 src/ui/ribbon/modify_tools/13_draworder.rs diff --git a/assets/icons/modify_draworder.svg b/assets/icons/modify_draworder.svg new file mode 100644 index 000000000..a5ae19f04 --- /dev/null +++ b/assets/icons/modify_draworder.svg @@ -0,0 +1 @@ + diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index e3650b5d1..b7ac14663 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -598,6 +598,10 @@ inventory::submit!(crate::command::CommandRegistration { "DIMSTYLE", "DONATE", "DRAWORDER", + "DRAWORDER_FRONT", + "DRAWORDER_BACK", + "DRAWORDER_ABOVE", + "DRAWORDER_UNDER", "DWGPROP", "DWGPROPS", "EATTEXT", diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 2ac432f68..33a936d2a 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -941,6 +941,37 @@ impl OpenCADStudio { return Some(Task::none()); } + "DRAWORDER_FRONT" | "DRAWORDER_BACK" | "DRAWORDER_ABOVE" | "DRAWORDER_UNDER" => { + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + if selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::plain("DRAWORDER", cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else if matches!(cmd, "DRAWORDER_ABOVE" | "DRAWORDER_UNDER") { + let command = DrawOrderCommand::for_reference_pick( + selected, + cmd == "DRAWORDER_ABOVE", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + let primitive = if cmd == "DRAWORDER_FRONT" { + "DRAWORDER FRONT" + } else { + "DRAWORDER BACK" + }; + return Some(self.apply_cmd_result(crate::command::CmdResult::Relaunch( + primitive.to_string(), + selected, + ))); + } + } "DRAWORDER" => { let selected: Vec = self.tabs[i] .scene diff --git a/src/ui/ribbon/modify_tools/13_draworder.rs b/src/ui/ribbon/modify_tools/13_draworder.rs new file mode 100644 index 000000000..ecd78fbdf --- /dev/null +++ b/src/ui/ribbon/modify_tools/13_draworder.rs @@ -0,0 +1,6 @@ +Tool { + command: "DRAWORDER_FRONT", + label: "Draw Order", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_draworder.svg")), + options: &[("DRAWORDER_FRONT", "Bring to Front"), ("DRAWORDER_BACK", "Send to Back"), ("DRAWORDER_ABOVE", "Bring Above Objects"), ("DRAWORDER_UNDER", "Send Under Objects")], +} From 9b58ca8a93c533b29d628f7a6ab3e4e301284247 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 09:45:57 +0300 Subject: [PATCH 32/99] Harden parametric sketch constraints --- .gitignore | 6 - CHANGELOG.md | 147 -- Cargo.lock | 19 +- Cargo.toml | 5 +- build.rs | 32 - crates/ocs_doc_api/Cargo.toml | 2 +- crates/ocs_gcs/Cargo.toml | 13 - .../ocs_gcs/src/constraints/angle_distance.rs | 224 --- crates/ocs_gcs/src/constraints/bspline.rs | 216 --- crates/ocs_gcs/src/constraints/circle_arc.rs | 478 ------ crates/ocs_gcs/src/constraints/conic.rs | 424 ----- .../ocs_gcs/src/constraints/curve_generic.rs | 498 ------ crates/ocs_gcs/src/constraints/mod.rs | 108 -- crates/ocs_gcs/src/constraints/point_line.rs | 956 ----------- crates/ocs_gcs/src/diagnosis.rs | 265 --- crates/ocs_gcs/src/geo.rs | 1169 ------------- crates/ocs_gcs/src/graph.rs | 140 -- crates/ocs_gcs/src/lib.rs | 15 - crates/ocs_gcs/src/qp_eq.rs | 182 --- crates/ocs_gcs/src/solvers/bfgs.rs | 169 -- crates/ocs_gcs/src/solvers/dogleg.rs | 209 --- crates/ocs_gcs/src/solvers/lm.rs | 176 -- crates/ocs_gcs/src/solvers/mod.rs | 38 - crates/ocs_gcs/src/subsystem.rs | 268 --- crates/ocs_gcs/src/system.rs | 155 -- crates/ocs_gcs/src/util.rs | 161 -- docs/backlog.md | 148 -- docs/command_reference.md | 1443 ----------------- docs/dwg_constraint_compatibility_design.md | 414 ----- docs/named_parameters_design.md | 477 ------ docs/parametric_system_design.md | 1170 ------------- help/3d-modeling.html | 55 - help/README.md | 43 - help/annotation-dimensions.html | 55 - help/assets/css/core.css | 190 --- help/assets/css/opencad.css | 24 - help/assets/images/README.md | 9 - help/assets/js/help.js | 120 -- help/automation-plugins.html | 55 - help/blocks-references.html | 55 - help/commands-shortcuts.html | 55 - help/context-map.json | 45 - help/create-edit-geometry.html | 57 - help/files-drawings.html | 57 - help/getting-started.html | 64 - help/index.html | 61 - help/layers-properties.html | 55 - help/layouts-plotting.html | 55 - help/manual.md | 305 ---- help/parametric-constraints.html | 55 - help/search-index.js | 1 - help/tools/build_help.py | 256 --- help/tools/check_help.py | 100 -- help/troubleshooting-recovery.html | 59 - help/workspace-navigation.html | 57 - packaging/Info.plist | 4 +- packaging/build_macos_signed.sh | 11 +- src/app/automation.rs | 2 - src/app/command_driver.rs | 291 ++-- src/app/commands/draw.rs | 4 +- src/app/commands/mod.rs | 6 +- src/app/commands/view.rs | 25 +- src/app/config.rs | 17 +- src/app/document.rs | 56 +- src/app/history.rs | 71 +- src/app/mod.rs | 57 +- src/app/properties.rs | 3 +- src/app/settings.rs | 18 +- src/app/update/file.rs | 17 +- src/app/update/mod.rs | 18 - src/app/update/viewport.rs | 12 +- src/app/view/mod.rs | 4 +- src/app/view/modal.rs | 3 - src/command.rs | 28 +- src/entities/curve.rs | 12 +- src/modules/draw/constrain/coincident.rs | 16 +- src/modules/draw/constrain/equal_distance.rs | 22 +- src/modules/draw/constrain/mod.rs | 10 +- src/modules/draw/constrain/point_on_entity.rs | 37 +- src/modules/draw/constrain/tools.rs | 47 +- src/modules/draw/constrain/value.rs | 337 +++- src/scene/dwg_native_constraints.rs | 734 ++++++--- src/scene/mod.rs | 52 +- src/scene/modify.rs | 5 +- src/scene/named_parameters.rs | 250 +-- src/scene/named_parameters_persist.rs | 114 +- src/scene/pipeline/mesh_gpu.rs | 2 +- src/scene/sketch_constraints.rs | 436 +++-- src/scene/sketch_persist.rs | 183 ++- src/scene/sketch_solve.rs | 1031 ++++++++---- src/scene/text/ttf_glyph.rs | 106 +- src/ui/overlay.rs | 16 +- src/ui/properties.rs | 3 +- src/ui/ribbon/draw_panel.rs | 6 +- src/ui/ribbon/mod.rs | 57 +- src/ui/ribbon/widgets.rs | 120 +- src/ui/statusbar/mod.rs | 19 +- src/ui/window/named_parameters.rs | 143 +- src/ui/window/options.rs | 79 +- tests/sketch_constraints_solve.rs | 736 ++++++--- tests/sketch_constraints_xrecord_roundtrip.rs | 60 +- 101 files changed, 3128 insertions(+), 13767 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 crates/ocs_gcs/Cargo.toml delete mode 100644 crates/ocs_gcs/src/constraints/angle_distance.rs delete mode 100644 crates/ocs_gcs/src/constraints/bspline.rs delete mode 100644 crates/ocs_gcs/src/constraints/circle_arc.rs delete mode 100644 crates/ocs_gcs/src/constraints/conic.rs delete mode 100644 crates/ocs_gcs/src/constraints/curve_generic.rs delete mode 100644 crates/ocs_gcs/src/constraints/mod.rs delete mode 100644 crates/ocs_gcs/src/constraints/point_line.rs delete mode 100644 crates/ocs_gcs/src/diagnosis.rs delete mode 100644 crates/ocs_gcs/src/geo.rs delete mode 100644 crates/ocs_gcs/src/graph.rs delete mode 100644 crates/ocs_gcs/src/lib.rs delete mode 100644 crates/ocs_gcs/src/qp_eq.rs delete mode 100644 crates/ocs_gcs/src/solvers/bfgs.rs delete mode 100644 crates/ocs_gcs/src/solvers/dogleg.rs delete mode 100644 crates/ocs_gcs/src/solvers/lm.rs delete mode 100644 crates/ocs_gcs/src/solvers/mod.rs delete mode 100644 crates/ocs_gcs/src/subsystem.rs delete mode 100644 crates/ocs_gcs/src/system.rs delete mode 100644 crates/ocs_gcs/src/util.rs delete mode 100644 docs/backlog.md delete mode 100644 docs/command_reference.md delete mode 100644 docs/dwg_constraint_compatibility_design.md delete mode 100644 docs/named_parameters_design.md delete mode 100644 docs/parametric_system_design.md delete mode 100644 help/3d-modeling.html delete mode 100644 help/README.md delete mode 100644 help/annotation-dimensions.html delete mode 100644 help/assets/css/core.css delete mode 100644 help/assets/css/opencad.css delete mode 100644 help/assets/images/README.md delete mode 100644 help/assets/js/help.js delete mode 100644 help/automation-plugins.html delete mode 100644 help/blocks-references.html delete mode 100644 help/commands-shortcuts.html delete mode 100644 help/context-map.json delete mode 100644 help/create-edit-geometry.html delete mode 100644 help/files-drawings.html delete mode 100644 help/getting-started.html delete mode 100644 help/index.html delete mode 100644 help/layers-properties.html delete mode 100644 help/layouts-plotting.html delete mode 100644 help/manual.md delete mode 100644 help/parametric-constraints.html delete mode 100644 help/search-index.js delete mode 100644 help/tools/build_help.py delete mode 100644 help/tools/check_help.py delete mode 100644 help/troubleshooting-recovery.html delete mode 100644 help/workspace-navigation.html diff --git a/.gitignore b/.gitignore index 037fbc55b..80e292024 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,3 @@ __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/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 6dfc789b6..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,147 +0,0 @@ -# Changelog - -Notable changes on `feature/parametric-constraint-system` since each push to -GitHub. Newest entries first. "Unreleased" covers local changes not yet -pushed. - -## Unreleased (since `3d0a41b6`) - -- **Feature:** Properties panel gains a "Parameters" section (shown with no - selection) for inline named-parameter name/formula editing, add, and - delete, and a "Constraints" section (shown for a single selected entity) - listing every constraint touching it as a clickable link that selects - the linked entities in the viewport — danger-tinted when the constraint - conflicts. A newly-added parameter's formula now defaults to `1`, not - `0`, since a 0-length seed value is a genuine solver singularity for a - Distance constraint driven by that parameter (no defined direction to - grow back out of). - ([command_driver.rs](src/app/command_driver.rs), - [properties.rs](src/app/properties.rs), - [ui/properties.rs](src/ui/properties.rs)) - -- **Feature:** new Options > General toggle, "Show values and parameter - names on constraint markers" (on by default, persisted). Off, every - constraint pill in the viewport shows just its bare glyph instead of - glyph-plus-driven-value/parameter-name — the glyph alone is enough to - see a constraint is present, without the value text covering nearby - geometry on a dense sketch. - ([options.rs](src/ui/window/options.rs), - [view/mod.rs](src/app/view/mod.rs)) - -- **Feature:** all 18 constraint-ribbon icons (including the plain "F", - "L", "M", "S" letter glyphs) replaced with proper SVGs under - `assets/icons/constrain/`, matching the Modify section's two-color style - (`#B4B6B9` white / `#6DB7ED` blue, consistent stroke width). Tangent - reuses the Draw section's white-arc-plus-blue-dots motif. - ([assets/icons/constrain](assets/icons/constrain), - [tools.rs](src/modules/draw/constrain/tools.rs)) - -- **Fix:** a Concentric or CenterPoint constraint that moved an ellipse's - center visibly rotated and reshaped the ellipse, even though nothing - constrained its shape. Root cause: `ocs_gcs::geo::Ellipse` stores - `focus1` as an absolute point rather than an offset from `center`, so - when a constraint pulled only on `center`, `focus1`'s absolute - coordinates stayed put while `center` moved — changing the derived - `focus1 - center` vector that `major_axis`/`minor_axis_ratio` are - computed from on write-back. The solver now adds two constraints per - registered ellipse pinning that offset to its pre-solve value, so - `focus1` translates rigidly with `center` unless something actually - constrains it. - ([sketch_solve.rs](src/scene/sketch_solve.rs)) - -- **Fix:** a sketch's constraint set (or the named-parameter table) silently - lost *all* of its entries on the next DWG open once its serialized size - passed 255 bytes — roughly 5-6 constraints, easily reached by a real - sketch. Root cause: the vendored DWG writer packs an `XRecord` binary - `Chunk` entry's length as a single `u8` and truncates anything longer - (`cadcodec`'s `encode_xrecord_entries`), so the truncated bytes then - failed to `bincode::deserialize` on load and the loader silently dropped - the whole scope rather than erroring. DXF was unaffected (its writer has - no such cap), which is what made this findable — the exact same document - round-tripped constraint-for-constraint through DXF and came back empty - through DWG. Fixed entirely on the OCS side, no dependency patch needed: - the blob is now split across as many same-code `310` entries as it takes - (below 255 bytes each) on save, and every entry for that key is - concatenated back together on load, instead of just the first one. - ([sketch_persist.rs](src/scene/sketch_persist.rs), - [named_parameters_persist.rs](src/scene/named_parameters_persist.rs)) - -- **Fix:** none of the 17 sketch-constraint commands (`HCONSTRAINT`, - `VCONSTRAINT`, `PCONSTRAINT`, `QCONSTRAINT`, `ECONSTRAINT`, `TCONSTRAINT`, - `NCONSTRAINT`, `NRCONSTRAINT`, `LCONSTRAINT`, `FXCONSTRAINT`, - `SYCONSTRAINT`, `CCONSTRAINT`, `EDCONSTRAINT`, `CPCONSTRAINT`, - `MPCONSTRAINT`, `OCCONSTRAINT`, `DCONSTRAINT`, `ACONSTRAINT`) carried an - `inventory::submit!` registration — a pre-existing gap the - `docs/command_reference.md` generator had already flagged, which meant - every constraint command (including this session's new `NRCONSTRAINT`) - was invisible to command-line autocomplete and the MCP server's - `commands` listing, despite working fine when typed exactly. Each - constraint module now registers itself, with a regression test pinning - it so the gap can't silently reappear. - - The same missing registration also broke the **keyboard-shortcut - editor** (`ALIASEDIT`): it validates a custom shortcut's target - against this same registry and silently rejected anything not in it, - so a user could not assign a custom shortcut to *any* constraint - command — not just the new ones, the original `HCONSTRAINT`/ - `CCONSTRAINT`/etc. family too. Fixed by the same change. - ([tools.rs](src/modules/draw/constrain/tools.rs), - [coincident.rs](src/modules/draw/constrain/coincident.rs), - [equal_distance.rs](src/modules/draw/constrain/equal_distance.rs), - [point_on_entity.rs](src/modules/draw/constrain/point_on_entity.rs), - [value.rs](src/modules/draw/constrain/value.rs), - [command.rs](src/command.rs)) - -## Pushed 2026-09-10 (`eb96e8b6` → `3d0a41b6`) - -- **Fix:** a dimensional constraint's value prompt (e.g. `DCONSTRAINT`'s - "Specify distance:") rejected a named parameter typed in its own - lowercase name — the command line uppercases typed text before it - reaches the parser, so `r` became `R` and no longer matched the - parameter table's case-sensitive lookup. `parse_driving_value` now - matches parameter names case-insensitively and resolves to the - parameter's canonical name. - ([value.rs](src/modules/draw/constrain/value.rs)) -- **Feature:** Named Parameters panel gains a "Used by" column showing - which constraints (kind + entity handles) currently reference each - parameter, with a hover tooltip listing the full per-constraint entity - breakdown when more than a couple of constraints use it. Backed by a new - `Scene::parameter_usage(name)` query that searches every sketch scope - (model space and blocks) and deduplicates an entity referenced by more - than one of a constraint's point markers. - ([named_parameters.rs](src/ui/window/named_parameters.rs), - [sketch_constraints.rs](src/scene/sketch_constraints.rs)) -- Named Parameters modal widened (620 → 820) to fit the new column. - ([modal.rs](src/app/view/modal.rs)) -- **Feature:** constraint-solver support extended well beyond Line/Circle, - closing most of the gap with FreeCAD's Sketcher (grounded against real - ObjectARX headers so DWG-native persistence stays honest about what - AutoCAD itself can represent): - - Arcs are now full constraint participants: center/radius (Concentric, - Tangent, Equal, CenterPoint, Radius, Diameter, Fixed, Normal) via the - same solver representation a Circle uses, *and* actual endpoints - (Coincident/PointOnCurve/Midpoint/EqualDistance/Symmetric on an arc's - real start/end point, marker 0/1) — kept numerically consistent with - center/radius/angle by "arc rules" constraints added for every - registered arc, reusing an already-ported-but-unused `ocs_gcs` - primitive (`CurveValue`) rather than new low-level math. - - New dimensional kinds: `Diameter`, `DistanceX`, `DistanceY`, - `ArcLength` — reusing existing DWG classes (Diameter/DistanceX/Y ride - the same `ACRADIUSDIAMETERCONSTRAINT`/`ACDISTANCECONSTRAINT` objects - Radius/Distance already use, with a different mode/direction byte, - matching how AutoCAD itself represents them). `ArcLength` has no DWG - equivalent at all (confirmed against ObjectARX's own headers) and is - XRecord-only by design, not omission. - - New geometric kind: `Normal` (a line perpendicular to a circle/arc's - tangent), matching AutoCAD's real `kNormal` constraint type. - - New entity: `Ellipse` (center/Concentric/CenterPoint/Fixed only — - major/minor axis dimensional constraints and `PointOnEllipse`-based - Coincident/Tangent remain a follow-up). DWG-native persistence for - ellipse-referencing constraints is deliberately not implemented yet — - `acadrust`'s `Ellipse`/`BoundedEllipse` node variants have a different - field shape than the geometry-dependency role Circle/Arc play, and - guessing the semantics risked writing a plausible-but-wrong object - graph, so it degrades to XRecord-only instead. - ([sketch_solve.rs](src/scene/sketch_solve.rs), - [sketch_constraints.rs](src/scene/sketch_constraints.rs), - [dwg_native_constraints.rs](src/scene/dwg_native_constraints.rs), - [value.rs](src/modules/draw/constrain/value.rs)) diff --git a/Cargo.lock b/Cargo.lock index 93d22681c..0f4d36ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ dependencies = [ "bincode", "bytemuck", "cadkernel", + "cadkernel-constraints", "clap", "console_error_panic_hook", "cosmic-text 0.15.0", @@ -39,7 +40,6 @@ dependencies = [ "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "ocs_doc_api", - "ocs_gcs", "ocs_plugin_api", "open", "printpdf", @@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0507b720fef47bd7da0d4f777f97f1743a4f4038#0507b720fef47bd7da0d4f777f97f1743a4f4038" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7d12f4a87c4737bb83fdad40e613468513d38904#7d12f4a87c4737bb83fdad40e613468513d38904" dependencies = [ "acadrust", "cavalier_contours", @@ -903,6 +903,14 @@ dependencies = [ "spade", ] +[[package]] +name = "cadkernel-constraints" +version = "0.1.0" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7d12f4a87c4737bb83fdad40e613468513d38904#7d12f4a87c4737bb83fdad40e613468513d38904" +dependencies = [ + "nalgebra", +] + [[package]] name = "calloop" version = "0.13.0" @@ -3952,13 +3960,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 34cc635e3..ae0490a04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,14 +12,12 @@ 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" @@ -34,7 +32,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7d12f4a87c4737bb83fdad40e613468513d38904", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7d12f4a87c4737bb83fdad40e613468513d38904" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/build.rs b/build.rs index ba22f06dc..fd50c581c 100644 --- a/build.rs +++ b/build.rs @@ -3,29 +3,6 @@ #[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(); @@ -43,15 +20,6 @@ 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_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 744d3559e..63f6568a0 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7d12f4a87c4737bb83fdad40e613468513d38904", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_gcs/Cargo.toml b/crates/ocs_gcs/Cargo.toml deleted file mode 100644 index 1ebac4af0..000000000 --- a/crates/ocs_gcs/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[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 deleted file mode 100644 index 6a853afaf..000000000 --- a/crates/ocs_gcs/src/constraints/angle_distance.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! 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 deleted file mode 100644 index ed23b850d..000000000 --- a/crates/ocs_gcs/src/constraints/bspline.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! `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 deleted file mode 100644 index 9b1ede99a..000000000 --- a/crates/ocs_gcs/src/constraints/circle_arc.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! 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 deleted file mode 100644 index 3d6ce1655..000000000 --- a/crates/ocs_gcs/src/constraints/conic.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! 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 deleted file mode 100644 index 35cc51509..000000000 --- a/crates/ocs_gcs/src/constraints/curve_generic.rs +++ /dev/null @@ -1,498 +0,0 @@ -//! 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 deleted file mode 100644 index 81bc16cd3..000000000 --- a/crates/ocs_gcs/src/constraints/mod.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! 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 deleted file mode 100644 index dfdbc2821..000000000 --- a/crates/ocs_gcs/src/constraints/point_line.rs +++ /dev/null @@ -1,956 +0,0 @@ -//! 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 deleted file mode 100644 index db806a462..000000000 --- a/crates/ocs_gcs/src/diagnosis.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! 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 deleted file mode 100644 index 698a817d9..000000000 --- a/crates/ocs_gcs/src/geo.rs +++ /dev/null @@ -1,1169 +0,0 @@ -//! 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 deleted file mode 100644 index bd0cba20b..000000000 --- a/crates/ocs_gcs/src/graph.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! 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 deleted file mode 100644 index 1f01c8e5d..000000000 --- a/crates/ocs_gcs/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! 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 deleted file mode 100644 index 8aac36b88..000000000 --- a/crates/ocs_gcs/src/qp_eq.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 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 deleted file mode 100644 index 2324262aa..000000000 --- a/crates/ocs_gcs/src/solvers/bfgs.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! 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 deleted file mode 100644 index 9150e45f9..000000000 --- a/crates/ocs_gcs/src/solvers/dogleg.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! 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 deleted file mode 100644 index a34445a0a..000000000 --- a/crates/ocs_gcs/src/solvers/lm.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! 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 deleted file mode 100644 index a04ee0538..000000000 --- a/crates/ocs_gcs/src/solvers/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! 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 deleted file mode 100644 index a29c249b1..000000000 --- a/crates/ocs_gcs/src/subsystem.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! 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 deleted file mode 100644 index c9b9b42bb..000000000 --- a/crates/ocs_gcs/src/system.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! 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 deleted file mode 100644 index d42437c73..000000000 --- a/crates/ocs_gcs/src/util.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! 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 deleted file mode 100644 index f36cbb6b4..000000000 --- a/docs/backlog.md +++ /dev/null @@ -1,148 +0,0 @@ -# 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 deleted file mode 100644 index 47a983605..000000000 --- a/docs/command_reference.md +++ /dev/null @@ -1,1443 +0,0 @@ -# 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] - - - - - -
-
-

OpenCAD Studio User Manual

-

3D Modeling and Visualization

-

Solids and surfaces

-

OpenCAD Studio can create and display solid primitives and modeled features such as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for display while exact body data is retained where supported.

-

Visual styles and lighting

-

Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, not the underlying object geometry. Switch to wireframe when diagnosing missing faces or material problems.

-

Tessellation quality

-

Curves and analytic surfaces are converted into display geometry according to view and tolerance settings. A coarse display does not necessarily mean the stored curve or solid is coarse.

- - -
-
- - diff --git a/help/README.md b/help/README.md deleted file mode 100644 index 236cce877..000000000 --- a/help/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# OpenCAD Studio Help - -This directory is a self-contained, static user-help system. It is intentionally -not wired into the application yet. - -## Authoring model - -- `manual.md` is the single source of truth for user-facing prose. -- `tools/build_help.py` splits the level-two chapters into static HTML pages. -- `assets/css/` and `assets/js/` provide the shared responsive presentation, - navigation, dark mode, print styling, and local search. -- `context-map.json` reserves stable help targets for future command/dialog - integration without changing application source now. -- `assets/images/` is for screenshots and workflow illustrations referenced by - `manual.md`. - -Generated HTML and `search-index.js` are committed so the manual works directly -from disk without a web server. Do not edit generated pages by hand. - -## Build and validate - -Requirements: Python 3 and Pandoc. - -```sh -python3 help/tools/build_help.py -python3 help/tools/build_help.py --check -python3 help/tools/check_help.py -``` - -Open `help/index.html` in a browser to review the generated manual. When future -application work is safe, package the complete `help/` directory and connect the -Help menu or contextual controls to the URLs in `context-map.json`. - -## Content rules - -1. Keep level-two headings unique; each one is a chapter boundary. -2. Keep level-three headings stable because their generated IDs are public help - targets. -3. Describe observable behavior, not implementation details. -4. Use task-first procedures and state prerequisites before numbered steps. -5. Mark safety, compatibility, data-loss, and known limitations explicitly. -6. Regenerate and run both checks before committing documentation changes. - diff --git a/help/annotation-dimensions.html b/help/annotation-dimensions.html deleted file mode 100644 index d1f794516..000000000 --- a/help/annotation-dimensions.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Annotation and Dimensions — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Annotation and Dimensions

-

Text and annotation

-

Single-line text, multiline text, leaders, multileaders, tables, tolerances, center marks, and centerlines communicate design intent. Missing fonts can change layout or substitute glyphs, especially in the web build.

-

Dimensions

-

Dimensions combine measured geometry with a dimension style. Associative dimensions update when their source geometry changes; non-associative or partially associated dimensions require manual verification.

-

Annotation scale

-

Annotative objects can display differently at different viewport scales. Confirm the active annotation scale before creating annotations and inspect all paper-space viewports before plotting.

- - -
-
- - diff --git a/help/assets/css/core.css b/help/assets/css/core.css deleted file mode 100644 index f1e77ce0c..000000000 --- a/help/assets/css/core.css +++ /dev/null @@ -1,190 +0,0 @@ -:root { - color-scheme: light dark; - --accent: #1677d2; - --accent-strong: #075cae; - --accent-soft: #eaf4ff; - --canvas: #ffffff; - --chrome: rgba(20, 27, 37, 0.96); - --line: #d7dee7; - --muted: #617083; - --sidebar: #f4f7fa; - --text: #16202c; - --warning: #fff4cc; - --danger: #a62b2b; - --sidebar-width: 286px; - --topbar-height: 62px; -} - -* { box-sizing: border-box; } -html { scroll-padding-top: calc(var(--topbar-height) + 20px); } - -body { - margin: 0; - background: var(--canvas); - color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-size: 16px; - line-height: 1.62; -} - -a { color: var(--accent-strong); text-decoration-thickness: .08em; text-underline-offset: .16em; } -a:hover { color: var(--accent); } -a:focus-visible, button:focus-visible, input:focus-visible { - outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); - outline-offset: 2px; -} - -.topbar { - position: fixed; - inset: 0 0 auto; - z-index: 30; - height: var(--topbar-height); - display: flex; - align-items: center; - gap: 14px; - padding: 0 18px; - background: var(--chrome); - border-bottom: 1px solid rgba(255, 255, 255, .13); - backdrop-filter: blur(16px); -} - -.brand { min-width: 190px; color: #fff; font-size: 18px; font-weight: 720; text-decoration: none; } -.brand:hover { color: #fff; } -.nav-toggle { - width: 38px; - height: 38px; - border: 0; - border-radius: 8px; - background: transparent; - color: #fff; - cursor: pointer; - font-size: 22px; -} -.nav-toggle:hover { background: rgba(255, 255, 255, .12); } - -.search-shell { position: relative; width: min(520px, 55vw); margin-left: auto; } -.help-search { - width: 100%; - height: 38px; - padding: 0 42px 0 13px; - border: 1px solid rgba(255, 255, 255, .24); - border-radius: 9px; - background: rgba(255, 255, 255, .11); - color: #fff; - font: inherit; -} -.help-search::placeholder { color: rgba(255, 255, 255, .68); } -.search-key { position: absolute; top: 8px; right: 10px; color: rgba(255, 255, 255, .64); font-size: 13px; } -.search-results { - position: absolute; - top: 44px; - right: 0; - width: min(620px, calc(100vw - 24px)); - max-height: min(70vh, 560px); - overflow: auto; - margin: 0; - padding: 7px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--canvas); - box-shadow: 0 18px 50px rgba(0, 0, 0, .28); - list-style: none; -} -.search-results[hidden] { display: none; } -.search-results a { display: block; padding: 10px 11px; border-radius: 7px; color: var(--text); text-decoration: none; } -.search-results a:hover, .search-results a[aria-selected="true"] { background: var(--accent-soft); } -.search-results strong { display: block; color: var(--accent-strong); } -.search-results span { display: block; color: var(--muted); font-size: 13px; } -.search-empty { padding: 12px; color: var(--muted); } - -.sidebar { - position: fixed; - z-index: 20; - inset: var(--topbar-height) auto 0 0; - width: var(--sidebar-width); - padding: 16px 13px 36px; - overflow: auto; - background: var(--sidebar); - border-right: 1px solid var(--line); - transition: transform 180ms ease; -} -body.nav-collapsed .sidebar { transform: translateX(-100%); } -.sidebar details, .sidebar > div { margin: 3px 0; border-radius: 8px; } -.sidebar details.current-topic, .sidebar > div.current-topic { background: var(--canvas); box-shadow: 0 0 0 1px var(--line); } -.sidebar summary { padding: 7px 9px; font-weight: 680; cursor: pointer; } -.sidebar .chapter-link { display: block; padding: 7px 9px; font-weight: 680; } -.sidebar ul { margin: 0; padding: 0 9px 9px 27px; list-style: none; } -.sidebar li { margin: 2px 0; line-height: 1.35; } -.sidebar li a { display: block; padding: 3px 0; color: var(--text); font-size: 13px; text-decoration: none; } -.sidebar li a:hover { color: var(--accent); } - -#main-content { - min-height: 100vh; - margin-left: var(--sidebar-width); - padding: calc(var(--topbar-height) + 38px) 34px 70px; - transition: margin-left 180ms ease; -} -body.nav-collapsed #main-content { margin-left: 0; } -.content-wrap { max-width: 940px; margin: 0 auto; } -.page-kicker { margin: 0; color: var(--accent-strong); font-size: 12px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; } -.content-title { margin: 3px 0 30px; font-size: clamp(34px, 6vw, 52px); line-height: 1.06; letter-spacing: -.035em; } - -h2 { margin: 44px 0 17px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); font-size: 23px; line-height: 1.3; } -h3 { margin: 34px 0 8px; font-size: 19px; line-height: 1.35; } -p { margin: 9px 0 16px; } -ul, ol { padding-left: 26px; } -li { margin: 5px 0; } -code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; } -code { color: #a0440c; font-size: .91em; } -blockquote { - margin: 18px 0; - padding: 12px 15px; - border: 1px solid color-mix(in srgb, #d5a000 45%, var(--line)); - border-left: 4px solid #d5a000; - border-radius: 8px; - background: var(--warning); - color: #503f05; -} -blockquote p { margin: 0; } - -.page-navigation { display: flex; justify-content: space-between; gap: 20px; margin-top: 58px; padding-top: 18px; border-top: 1px solid var(--line); } -.page-navigation a:last-child { margin-left: auto; } -.date-footer { margin-top: 34px; color: var(--muted); font-size: 13px; text-align: center; } -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } - -@media (max-width: 760px) { - .brand { min-width: 0; font-size: 16px; } - .search-shell { width: min(48vw, 420px); } - .search-key { display: none; } - .sidebar { box-shadow: 12px 0 34px rgba(0, 0, 0, .22); } - body:not(.nav-open) .sidebar { transform: translateX(-100%); } - body.nav-open .sidebar { transform: translateX(0); } - #main-content, body.nav-collapsed #main-content { margin-left: 0; padding: calc(var(--topbar-height) + 28px) 18px 52px; } -} - -@media (prefers-color-scheme: dark) { - :root { - --accent: #66b6ff; - --accent-strong: #8bc7ff; - --accent-soft: #17344f; - --canvas: #161b22; - --chrome: rgba(14, 18, 24, .96); - --line: #374252; - --muted: #9eabba; - --sidebar: #1d242d; - --text: #eef3f8; - --warning: #423819; - } - code { color: #ffae79; } - blockquote { color: #f5e6ad; } -} - -@media print { - .topbar, .sidebar, .page-navigation { display: none !important; } - #main-content { margin: 0; padding: 0; } - body { color: #000; background: #fff; font-size: 11pt; } - a { color: inherit; } - h2, h3 { break-after: avoid; } - blockquote { break-inside: avoid; } -} - diff --git a/help/assets/css/opencad.css b/help/assets/css/opencad.css deleted file mode 100644 index 35f814a02..000000000 --- a/help/assets/css/opencad.css +++ /dev/null @@ -1,24 +0,0 @@ -.hero { - margin: 0 0 38px; - padding: clamp(25px, 6vw, 56px); - border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); - border-radius: 18px; - background: - linear-gradient(115deg, color-mix(in srgb, var(--accent-soft) 82%, var(--canvas)), var(--canvas)); -} -.hero .eyebrow { margin: 0; color: var(--accent-strong); font-size: 12px; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; } -.hero h2 { margin: 8px 0 14px; padding: 0; border: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.08; letter-spacing: -.03em; } -.hero p:last-child { max-width: 720px; margin-bottom: 0; font-size: 17px; } -.chapter-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.chapter-card { min-height: 130px; display: flex; flex-direction: column; gap: 7px; padding: 18px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); text-decoration: none; } -.chapter-card:hover { border-color: var(--accent); background: var(--accent-soft); text-decoration: none; } -.chapter-card strong { color: var(--accent-strong); font-size: 18px; } -.chapter-card span { color: var(--muted); } -.quick-paths { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 18px 0 28px; } -.quick-paths a { padding: 12px; border: 1px solid var(--line); border-radius: 9px; text-align: center; text-decoration: none; } -.quick-paths a:hover { background: var(--accent-soft); } -@media (max-width: 640px) { - .chapter-grid, .quick-paths { grid-template-columns: 1fr; } - .chapter-card { min-height: 0; } -} - diff --git a/help/assets/images/README.md b/help/assets/images/README.md deleted file mode 100644 index ebe08c420..000000000 --- a/help/assets/images/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Help images - -Place optimized screenshots and workflow illustrations in this directory. - -Use descriptive lowercase filenames, include meaningful alternative text in -`manual.md`, crop away unrelated desktop content, and avoid confidential drawing -names or paths. Prefer WebP or optimized PNG and keep each image at the smallest -resolution that remains legible on a high-density display. - diff --git a/help/assets/js/help.js b/help/assets/js/help.js deleted file mode 100644 index 336311a5c..000000000 --- a/help/assets/js/help.js +++ /dev/null @@ -1,120 +0,0 @@ -(function () { - "use strict"; - - const body = document.body; - const toggle = document.querySelector(".nav-toggle"); - const sidebar = document.getElementById("help-sidebar"); - const search = document.getElementById("help-search"); - const results = document.getElementById("search-results"); - const mobile = window.matchMedia("(max-width: 760px)"); - let selected = -1; - - function syncToggle() { - const expanded = mobile.matches - ? body.classList.contains("nav-open") - : !body.classList.contains("nav-collapsed"); - toggle?.setAttribute("aria-expanded", String(expanded)); - } - - toggle?.addEventListener("click", function () { - body.classList.toggle(mobile.matches ? "nav-open" : "nav-collapsed"); - syncToggle(); - }); - - sidebar?.addEventListener("click", function (event) { - if (mobile.matches && event.target.closest("a")) { - body.classList.remove("nav-open"); - syncToggle(); - } - }); - - mobile.addEventListener("change", function () { - body.classList.remove("nav-open"); - syncToggle(); - }); - - function hideResults() { - results.hidden = true; - results.innerHTML = ""; - selected = -1; - search?.setAttribute("aria-expanded", "false"); - } - - function renderResults(query) { - const terms = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean); - if (!terms.length) { - hideResults(); - return; - } - - const matches = (window.OCS_HELP_INDEX || []).map(function (entry) { - const title = entry.title.toLocaleLowerCase(); - const text = entry.text.toLocaleLowerCase(); - const score = terms.reduce(function (total, term) { - if (!text.includes(term) && !title.includes(term)) return -1000; - return total + (title.includes(term) ? 8 : 1); - }, 0); - return { entry: entry, score: score }; - }).filter(function (item) { return item.score >= 0; }) - .sort(function (a, b) { return b.score - a.score || a.entry.title.localeCompare(b.entry.title); }) - .slice(0, 12); - - results.innerHTML = ""; - if (!matches.length) { - const empty = document.createElement("li"); - empty.className = "search-empty"; - empty.textContent = "No matching help topics."; - results.appendChild(empty); - } else { - matches.forEach(function (item) { - const li = document.createElement("li"); - const link = document.createElement("a"); - link.href = item.entry.url; - const strong = document.createElement("strong"); - strong.textContent = item.entry.title; - const detail = document.createElement("span"); - detail.textContent = item.entry.chapter; - link.append(strong, detail); - li.appendChild(link); - results.appendChild(li); - }); - } - results.hidden = false; - search.setAttribute("aria-expanded", "true"); - selected = -1; - } - - search?.addEventListener("input", function () { renderResults(search.value); }); - search?.addEventListener("keydown", function (event) { - const links = Array.from(results.querySelectorAll("a")); - if (event.key === "Escape") { - search.value = ""; - hideResults(); - return; - } - if (!links.length || !["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return; - event.preventDefault(); - if (event.key === "Enter" && selected >= 0) { - links[selected].click(); - return; - } - if (event.key === "ArrowDown") selected = (selected + 1) % links.length; - if (event.key === "ArrowUp") selected = selected < 0 ? links.length - 1 : (selected - 1 + links.length) % links.length; - links.forEach(function (link, index) { link.setAttribute("aria-selected", String(index === selected)); }); - links[selected]?.scrollIntoView({ block: "nearest" }); - }); - - document.addEventListener("keydown", function (event) { - const shortcut = event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"); - if (shortcut && document.activeElement !== search) { - event.preventDefault(); - search?.focus(); - } - }); - - document.addEventListener("click", function (event) { - if (!event.target.closest(".search-shell")) hideResults(); - }); - - syncToggle(); -}()); diff --git a/help/automation-plugins.html b/help/automation-plugins.html deleted file mode 100644 index 772d1d5c1..000000000 --- a/help/automation-plugins.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Automation and Plugins — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Automation and Plugins

-

Automation interface

-

The automation interface can inspect documents, run commands, query geometry, capture the viewport, and perform batches. Callers should use request IDs and document revisions to avoid repeating or applying work to stale state.

-

Plugins

-

Native plugins can contribute commands and ribbon surfaces through the plugin host. Install only trusted packages that match the host API, compiler, and CAD model compatibility requirements.

-

Native and web differences

-

The browser build uses in-memory file workflows and cannot load native plugin libraries. Some rendering, font, windowing, and parallel-processing behavior also differs from the desktop build.

- - -
-
- - diff --git a/help/blocks-references.html b/help/blocks-references.html deleted file mode 100644 index 8502f0729..000000000 --- a/help/blocks-references.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Blocks and References — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Blocks and References

-

Create and insert blocks

-

A block definition groups reusable drawing content; an INSERT places an instance with its own position, rotation, scale, attributes, and optional array settings. Editing a definition affects every reference to it.

-

Array inserts

-

Array inserts place multiple block instances with row and column counts and spacing. Large counts can generate substantial geometry. Validate scaled and rotated arrays when exchanging drawings with another CAD system.

-

Attributes and external references

-

Attributes attach editable values to block references. External references and underlays retain links to other files; preserve their paths when sharing or archiving a project.

- - -
-
- - diff --git a/help/commands-shortcuts.html b/help/commands-shortcuts.html deleted file mode 100644 index 8471c3caf..000000000 --- a/help/commands-shortcuts.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Commands and Shortcuts — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Commands and Shortcuts

-

Command line

-

Enter a command name or alias, then respond to its prompts. Press Escape to cancel the active command and Return to accept an available default. Exact prompt options vary by command and current selection.

-

Discover commands

-

Use command autocomplete and the ribbon to discover available operations. Some commands are one-shot actions; others maintain state across several point, number, keyword, or object-selection inputs.

-

Shortcut conventions

-

Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows.

- - -
-
- - diff --git a/help/context-map.json b/help/context-map.json deleted file mode 100644 index d6bf4d820..000000000 --- a/help/context-map.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "schema_version": 1, - "fallback": "index.html", - "commands": { - "NEW": "files-drawings.html#create-and-open-drawings", - "OPEN": "files-drawings.html#create-and-open-drawings", - "SAVE": "files-drawings.html#save-and-save-as", - "SAVEAS": "files-drawings.html#save-and-save-as", - "RECOVER": "files-drawings.html#recovery-and-backups", - "LINE": "create-edit-geometry.html#basic-drawing-commands", - "CIRCLE": "create-edit-geometry.html#basic-drawing-commands", - "PLINE": "create-edit-geometry.html#basic-drawing-commands", - "ERASE": "create-edit-geometry.html#modify-geometry", - "UNDO": "create-edit-geometry.html#undo-redo-and-oops", - "REDO": "create-edit-geometry.html#undo-redo-and-oops", - "OOPS": "create-edit-geometry.html#undo-redo-and-oops", - "LAYER": "layers-properties.html#layers", - "CHPROP": "layers-properties.html#object-properties", - "BLOCK": "blocks-references.html#create-and-insert-blocks", - "INSERT": "blocks-references.html#create-and-insert-blocks", - "MINSERT": "blocks-references.html#array-inserts", - "DIM": "annotation-dimensions.html#dimensions", - "PLOT": "layouts-plotting.html#page-setup-and-plot-preview", - "EXTRUDE": "3d-modeling.html#solids-and-surfaces", - "REVOLVE": "3d-modeling.html#solids-and-surfaces", - "HCONSTRAINT": "parametric-constraints.html#geometric-constraints", - "VCONSTRAINT": "parametric-constraints.html#geometric-constraints", - "PCONSTRAINT": "parametric-constraints.html#geometric-constraints", - "QCONSTRAINT": "parametric-constraints.html#geometric-constraints", - "ECONSTRAINT": "parametric-constraints.html#geometric-constraints", - "CCONSTRAINT": "parametric-constraints.html#geometric-constraints", - "TCONSTRAINT": "parametric-constraints.html#geometric-constraints" - }, - "surfaces": { - "file-open": "files-drawings.html#create-and-open-drawings", - "file-save": "files-drawings.html#save-and-save-as", - "layers": "layers-properties.html#layers", - "properties": "layers-properties.html#object-properties", - "plot": "layouts-plotting.html#page-setup-and-plot-preview", - "plugin-manager": "automation-plugins.html#plugins", - "constraint-conflicts": "parametric-constraints.html#editing-constrained-geometry", - "recovery": "troubleshooting-recovery.html#a-drawing-opens-incorrectly" - } -} - diff --git a/help/create-edit-geometry.html b/help/create-edit-geometry.html deleted file mode 100644 index 0e35516f7..000000000 --- a/help/create-edit-geometry.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - Create and Edit Geometry — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Create and Edit Geometry

-

Basic drawing commands

-

Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, and other supported entities from the ribbon or command line. Follow the active prompt: multi-step commands remain active until they complete or are cancelled.

-

Modify geometry

-

Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification.

-

Grips and direct manipulation

-

Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it.

-

Undo, redo, and OOPS

-

Undo reverses recorded document operations and Redo reapplies them. OOPS restores the most recently erased entities without reversing later commands. Save before a risky sequence and verify that associated dimensions, hatches, groups, and constraints were restored with their geometry.

- - -
-
- - diff --git a/help/files-drawings.html b/help/files-drawings.html deleted file mode 100644 index 643206a8d..000000000 --- a/help/files-drawings.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - Files and Drawings — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Files and Drawings

-

Create and open drawings

-

Use New for a blank document and Open for an existing DWG, DXF, backup, or autosave drawing. After opening a file from another CAD system, inspect its units, layers, blocks, fonts, raster references, layouts, and model extents.

-

Save and Save As

-

Save updates the current drawing. Save As changes its path, format, or version. Prefer Save As when validating interoperability or recovering an older drawing. A successful write does not replace visual inspection of a reopened copy.

-

Recovery and backups

-

OpenCAD Studio recognizes drawing backups and autosave-style files where the underlying data is DWG or DXF. If strict loading rejects malformed entities, use the recovery workflow and review what was discarded before continuing work.

-

External resources

-

Raster images, underlays, fonts, materials, and external references may depend on paths outside the drawing. Keep referenced files together when moving a project and verify that relative paths still resolve.

- - -
-
- - diff --git a/help/getting-started.html b/help/getting-started.html deleted file mode 100644 index 7a6d82d1d..000000000 --- a/help/getting-started.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - Getting Started — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Getting Started

-

What OpenCAD Studio does

-

OpenCAD Studio is a desktop and web CAD application for opening, creating, editing, visualizing, and saving two- and three-dimensional drawings. It works with DWG and DXF drawings and provides command-line, ribbon, automation, and plugin-oriented workflows.

-

A first drawing

-
    -
  1. Create a new drawing or open an existing DWG or DXF file.
  2. -
  3. Confirm the active workspace, units, layer, and object-snap settings.
  4. -
  5. Create geometry with a drawing command such as Line, Circle, or Polyline.
  6. -
  7. Edit the result with grips or a modification command.
  8. -
  9. Save the drawing under a new name until its compatibility has been verified.
  10. -
-
-

Data safety: Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file.

-
-

How to use this manual

-

Use the chapter navigation for broad workflows, the search box for a command or concept, and the previous/next links for sequential reading. Command names, system variables, keys, and file extensions appear in monospaced text.

- - -
-
- - diff --git a/help/index.html b/help/index.html deleted file mode 100644 index 9f4dbed96..000000000 --- a/help/index.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - Help overview — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Help overview

-
-

OpenCAD Studio User Manual

-

Find the workflow, command, or concept you need

-

Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.

-
- -

Manual chapters

- - - -
-
- - diff --git a/help/layers-properties.html b/help/layers-properties.html deleted file mode 100644 index 457bfe56b..000000000 --- a/help/layers-properties.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Layers and Properties — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Layers and Properties

-

Layers

-

Layers organize geometry and control visibility, locking, color, line type, line weight, and plot behavior. Set the intended active layer before creating objects. Avoid editing objects on locked layers.

-

Object properties

-

Properties can be assigned explicitly or inherited ByLayer or ByBlock. Use the Properties surface or commands such as CHPROP to inspect and change selected objects.

-

Styles

-

Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving.

- - -
-
- - diff --git a/help/layouts-plotting.html b/help/layouts-plotting.html deleted file mode 100644 index 9a1214662..000000000 --- a/help/layouts-plotting.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Layouts and Plotting — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Layouts and Plotting

-

Layouts and viewports

-

Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes.

-

Page setup and plot preview

-

Choose the target device or file format, paper size, orientation, plot area, scale, style behavior, and output quality. Use preview to catch clipping, incorrect line weights, hidden layers, or an unintended plot area.

-

Export checks

-

Before distributing a PDF or plotted file, compare it with the drawing at the intended sheet size and verify text, dimensions, raster content, transparency, line types, and viewport scales.

- - -
-
- - diff --git a/help/manual.md b/help/manual.md deleted file mode 100644 index b366e7004..000000000 --- a/help/manual.md +++ /dev/null @@ -1,305 +0,0 @@ -# OpenCAD Studio User Manual - -This manual describes the user-facing workflows of OpenCAD Studio. The initial -version establishes the help structure and high-level guidance; individual -commands should be expanded and verified against released behavior before the -manual is published as complete. - -## Getting Started - -### What OpenCAD Studio does - -OpenCAD Studio is a desktop and web CAD application for opening, creating, -editing, visualizing, and saving two- and three-dimensional drawings. It works -with DWG and DXF drawings and provides command-line, ribbon, automation, and -plugin-oriented workflows. - -### A first drawing - -1. Create a new drawing or open an existing DWG or DXF file. -2. Confirm the active workspace, units, layer, and object-snap settings. -3. Create geometry with a drawing command such as **Line**, **Circle**, or - **Polyline**. -4. Edit the result with grips or a modification command. -5. Save the drawing under a new name until its compatibility has been verified. - -> **Data safety:** Keep an untouched copy of important third-party drawings. -> Confirm geometry, layouts, external references, and plotted output before -> replacing the original file. - -### How to use this manual - -Use the chapter navigation for broad workflows, the search box for a command or -concept, and the previous/next links for sequential reading. Command names, -system variables, keys, and file extensions appear in `monospaced text`. - -## Files and Drawings - -### Create and open drawings - -Use **New** for a blank document and **Open** for an existing DWG, DXF, backup, -or autosave drawing. After opening a file from another CAD system, inspect its -units, layers, blocks, fonts, raster references, layouts, and model extents. - -### Save and Save As - -**Save** updates the current drawing. **Save As** changes its path, format, or -version. Prefer Save As when validating interoperability or recovering an older -drawing. A successful write does not replace visual inspection of a reopened -copy. - -### Recovery and backups - -OpenCAD Studio recognizes drawing backups and autosave-style files where the -underlying data is DWG or DXF. If strict loading rejects malformed entities, use -the recovery workflow and review what was discarded before continuing work. - -### External resources - -Raster images, underlays, fonts, materials, and external references may depend -on paths outside the drawing. Keep referenced files together when moving a -project and verify that relative paths still resolve. - -## Workspace and Navigation - -### Model space and paper space - -Model space is the primary drawing environment. Paper-space layouts arrange -views for plotting through layout viewports. Always confirm the active space -before drawing or editing. - -### Pan, zoom, and orbit - -Use pan and zoom to navigate without changing drawing geometry. Use orbit and -view controls for three-dimensional inspection. **Zoom Extents** is useful when -the drawing appears empty or geometry is far from the expected location. - -### Multiple model viewports - -Model viewports can show different views of the same document. Grid, snap, -camera, and render mode may vary by viewport; confirm which viewport is active -before entering coordinates. - -### Selection and object snaps - -Selection identifies the objects a command will modify. Object snaps target -precise geometric locations such as endpoints, midpoints, centers, and -intersections. Zoom in and verify the snap marker when precision matters. - -## Create and Edit Geometry - -### Basic drawing commands - -Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, -and other supported entities from the ribbon or command line. Follow the active -prompt: multi-step commands remain active until they complete or are cancelled. - -### Modify geometry - -Common modification workflows include move, copy, rotate, scale, mirror, -offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked -layers can prevent modification. - -### Grips and direct manipulation - -Select an entity to expose its grips. Dragging a grip previews and then commits -a geometric change. Constrained neighboring geometry may update with it. - -### Undo, redo, and OOPS - -**Undo** reverses recorded document operations and **Redo** reapplies them. -**OOPS** restores the most recently erased entities without reversing later -commands. Save before a risky sequence and verify that associated dimensions, -hatches, groups, and constraints were restored with their geometry. - -## Layers and Properties - -### Layers - -Layers organize geometry and control visibility, locking, color, line type, -line weight, and plot behavior. Set the intended active layer before creating -objects. Avoid editing objects on locked layers. - -### Object properties - -Properties can be assigned explicitly or inherited **ByLayer** or **ByBlock**. -Use the Properties surface or commands such as `CHPROP` to inspect and change -selected objects. - -### Styles - -Text, dimension, multiline, table, and visual styles centralize presentation. -Changing a shared style can update many objects, so inspect affected layouts -before saving. - -## Blocks and References - -### Create and insert blocks - -A block definition groups reusable drawing content; an INSERT places an -instance with its own position, rotation, scale, attributes, and optional array -settings. Editing a definition affects every reference to it. - -### Array inserts - -Array inserts place multiple block instances with row and column counts and -spacing. Large counts can generate substantial geometry. Validate scaled and -rotated arrays when exchanging drawings with another CAD system. - -### Attributes and external references - -Attributes attach editable values to block references. External references and -underlays retain links to other files; preserve their paths when sharing or -archiving a project. - -## Annotation and Dimensions - -### Text and annotation - -Single-line text, multiline text, leaders, multileaders, tables, tolerances, -center marks, and centerlines communicate design intent. Missing fonts can -change layout or substitute glyphs, especially in the web build. - -### Dimensions - -Dimensions combine measured geometry with a dimension style. Associative -dimensions update when their source geometry changes; non-associative or -partially associated dimensions require manual verification. - -### Annotation scale - -Annotative objects can display differently at different viewport scales. -Confirm the active annotation scale before creating annotations and inspect all -paper-space viewports before plotting. - -## Layouts and Plotting - -### Layouts and viewports - -Layouts represent sheets. Paper-space viewports expose model-space views at -controlled scales. Lock finished viewports to avoid accidental view changes. - -### Page setup and plot preview - -Choose the target device or file format, paper size, orientation, plot area, -scale, style behavior, and output quality. Use preview to catch clipping, -incorrect line weights, hidden layers, or an unintended plot area. - -### Export checks - -Before distributing a PDF or plotted file, compare it with the drawing at the -intended sheet size and verify text, dimensions, raster content, transparency, -line types, and viewport scales. - -## 3D Modeling and Visualization - -### Solids and surfaces - -OpenCAD Studio can create and display solid primitives and modeled features such -as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for -display while exact body data is retained where supported. - -### Visual styles and lighting - -Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, -not the underlying object geometry. Switch to wireframe when diagnosing missing -faces or material problems. - -### Tessellation quality - -Curves and analytic surfaces are converted into display geometry according to -view and tolerance settings. A coarse display does not necessarily mean the -stored curve or solid is coarse. - -## Parametric Constraints - -### Geometric constraints - -Constraints capture relationships such as coincident, horizontal, vertical, -parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the -degree-of-freedom and conflict indicators to understand the sketch state. - -### Editing constrained geometry - -Moving or grip-editing constrained geometry can move neighboring objects. If a -solve fails or becomes redundant, inspect the constraint glyphs and conflict -status before deleting geometry. - -### Persistence and history limitations - -Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. -Other applications may preserve it without interpreting it. Constraint edits -and geometry changes may currently occupy adjacent history entries; verify the -result after undo, redo, erase, copy, and reopen operations. - -## Commands and Shortcuts - -### Command line - -Enter a command name or alias, then respond to its prompts. Press `Escape` to -cancel the active command and `Return` to accept an available default. Exact -prompt options vary by command and current selection. - -### Discover commands - -Use command autocomplete and the ribbon to discover available operations. Some -commands are one-shot actions; others maintain state across several point, -number, keyword, or object-selection inputs. - -### Shortcut conventions - -Application and operating-system shortcuts can differ between native and web -builds. Avoid assigning browser-reserved shortcuts to web-only workflows. - -## Automation and Plugins - -### Automation interface - -The automation interface can inspect documents, run commands, query geometry, -capture the viewport, and perform batches. Callers should use request IDs and -document revisions to avoid repeating or applying work to stale state. - -### Plugins - -Native plugins can contribute commands and ribbon surfaces through the plugin -host. Install only trusted packages that match the host API, compiler, and CAD -model compatibility requirements. - -### Native and web differences - -The browser build uses in-memory file workflows and cannot load native plugin -libraries. Some rendering, font, windowing, and parallel-processing behavior -also differs from the desktop build. - -## Troubleshooting and Recovery - -### A drawing opens incorrectly - -Check units, extents, layers, layouts, block placement, missing references, and -fonts. Reopen a copy after saving to detect serialization problems early. Keep -the original file available for comparison. - -### Geometry is missing or distorted - -Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block -scale, array settings, clipping boundaries, object normals, and coordinates far -from the origin. - -### Rendering is incomplete - -Reduce unusually large images or material textures, simplify extreme hatch or -array inputs, and compare another visual style. Record the graphics backend and -the smallest drawing that reproduces the issue. - -### Undo did not restore the expected state - -Stop editing and save a diagnostic copy. Try the next adjacent undo entry only -when you understand what it represents. Compare the result with autosave or -backup data before overwriting the original drawing. - -### Report a reproducible problem - -Include the OpenCAD Studio version, platform, native or web build, exact steps, -expected and observed results, and a minimal sanitized drawing when possible. -Do not publish confidential drawings or external-reference paths. - diff --git a/help/parametric-constraints.html b/help/parametric-constraints.html deleted file mode 100644 index 3ba9227ee..000000000 --- a/help/parametric-constraints.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Parametric Constraints — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Parametric Constraints

-

Geometric constraints

-

Constraints capture relationships such as coincident, horizontal, vertical, parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the degree-of-freedom and conflict indicators to understand the sketch state.

-

Editing constrained geometry

-

Moving or grip-editing constrained geometry can move neighboring objects. If a solve fails or becomes redundant, inspect the constraint glyphs and conflict status before deleting geometry.

-

Persistence and history limitations

-

Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. Other applications may preserve it without interpreting it. Constraint edits and geometry changes may currently occupy adjacent history entries; verify the result after undo, redo, erase, copy, and reopen operations.

- - -
-
- - diff --git a/help/search-index.js b/help/search-index.js deleted file mode 100644 index f6ef5f775..000000000 --- a/help/search-index.js +++ /dev/null @@ -1 +0,0 @@ -window.OCS_HELP_INDEX = [{"title":"What OpenCAD Studio does","chapter":"Getting Started","url":"getting-started.html#what-opencad-studio-does","text":"What OpenCAD Studio does OpenCAD Studio is a desktop and web CAD application for opening, creating, editing, visualizing, and saving two- and three-dimensional drawings. It works with DWG and DXF drawings and provides command-line, ribbon, automation, and plugin-oriented workflows."},{"title":"A first drawing","chapter":"Getting Started","url":"getting-started.html#a-first-drawing","text":"A first drawing Create a new drawing or open an existing DWG or DXF file. Confirm the active workspace, units, layer, and object-snap settings. Create geometry with a drawing command such as Line , Circle , or Polyline . Edit the result with grips or a modification command. Save the drawing under a new name until its compatibility has been verified. Data safety: Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file."},{"title":"How to use this manual","chapter":"Getting Started","url":"getting-started.html#how-to-use-this-manual","text":"How to use this manual Use the chapter navigation for broad workflows, the search box for a command or concept, and the previous/next links for sequential reading. Command names, system variables, keys, and file extensions appear in monospaced text ."},{"title":"Create and open drawings","chapter":"Files and Drawings","url":"files-drawings.html#create-and-open-drawings","text":"Create and open drawings Use New for a blank document and Open for an existing DWG, DXF, backup, or autosave drawing. After opening a file from another CAD system, inspect its units, layers, blocks, fonts, raster references, layouts, and model extents."},{"title":"Save and Save As","chapter":"Files and Drawings","url":"files-drawings.html#save-and-save-as","text":"Save and Save As Save updates the current drawing. Save As changes its path, format, or version. Prefer Save As when validating interoperability or recovering an older drawing. A successful write does not replace visual inspection of a reopened copy."},{"title":"Recovery and backups","chapter":"Files and Drawings","url":"files-drawings.html#recovery-and-backups","text":"Recovery and backups OpenCAD Studio recognizes drawing backups and autosave-style files where the underlying data is DWG or DXF. If strict loading rejects malformed entities, use the recovery workflow and review what was discarded before continuing work."},{"title":"External resources","chapter":"Files and Drawings","url":"files-drawings.html#external-resources","text":"External resources Raster images, underlays, fonts, materials, and external references may depend on paths outside the drawing. Keep referenced files together when moving a project and verify that relative paths still resolve."},{"title":"Model space and paper space","chapter":"Workspace and Navigation","url":"workspace-navigation.html#model-space-and-paper-space","text":"Model space and paper space Model space is the primary drawing environment. Paper-space layouts arrange views for plotting through layout viewports. Always confirm the active space before drawing or editing."},{"title":"Pan, zoom, and orbit","chapter":"Workspace and Navigation","url":"workspace-navigation.html#pan-zoom-and-orbit","text":"Pan, zoom, and orbit Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. Zoom Extents is useful when the drawing appears empty or geometry is far from the expected location."},{"title":"Multiple model viewports","chapter":"Workspace and Navigation","url":"workspace-navigation.html#multiple-model-viewports","text":"Multiple model viewports Model viewports can show different views of the same document. Grid, snap, camera, and render mode may vary by viewport; confirm which viewport is active before entering coordinates."},{"title":"Selection and object snaps","chapter":"Workspace and Navigation","url":"workspace-navigation.html#selection-and-object-snaps","text":"Selection and object snaps Selection identifies the objects a command will modify. Object snaps target precise geometric locations such as endpoints, midpoints, centers, and intersections. Zoom in and verify the snap marker when precision matters."},{"title":"Basic drawing commands","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#basic-drawing-commands","text":"Basic drawing commands Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, and other supported entities from the ribbon or command line. Follow the active prompt: multi-step commands remain active until they complete or are cancelled."},{"title":"Modify geometry","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#modify-geometry","text":"Modify geometry Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification."},{"title":"Grips and direct manipulation","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#grips-and-direct-manipulation","text":"Grips and direct manipulation Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it."},{"title":"Undo, redo, and OOPS","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#undo-redo-and-oops","text":"Undo, redo, and OOPS Undo reverses recorded document operations and Redo reapplies them. OOPS restores the most recently erased entities without reversing later commands. Save before a risky sequence and verify that associated dimensions, hatches, groups, and constraints were restored with their geometry."},{"title":"Layers","chapter":"Layers and Properties","url":"layers-properties.html#layers","text":"Layers Layers organize geometry and control visibility, locking, color, line type, line weight, and plot behavior. Set the intended active layer before creating objects. Avoid editing objects on locked layers."},{"title":"Object properties","chapter":"Layers and Properties","url":"layers-properties.html#object-properties","text":"Object properties Properties can be assigned explicitly or inherited ByLayer or ByBlock . Use the Properties surface or commands such as CHPROP to inspect and change selected objects."},{"title":"Styles","chapter":"Layers and Properties","url":"layers-properties.html#styles","text":"Styles Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving."},{"title":"Create and insert blocks","chapter":"Blocks and References","url":"blocks-references.html#create-and-insert-blocks","text":"Create and insert blocks A block definition groups reusable drawing content; an INSERT places an instance with its own position, rotation, scale, attributes, and optional array settings. Editing a definition affects every reference to it."},{"title":"Array inserts","chapter":"Blocks and References","url":"blocks-references.html#array-inserts","text":"Array inserts Array inserts place multiple block instances with row and column counts and spacing. Large counts can generate substantial geometry. Validate scaled and rotated arrays when exchanging drawings with another CAD system."},{"title":"Attributes and external references","chapter":"Blocks and References","url":"blocks-references.html#attributes-and-external-references","text":"Attributes and external references Attributes attach editable values to block references. External references and underlays retain links to other files; preserve their paths when sharing or archiving a project."},{"title":"Text and annotation","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#text-and-annotation","text":"Text and annotation Single-line text, multiline text, leaders, multileaders, tables, tolerances, center marks, and centerlines communicate design intent. Missing fonts can change layout or substitute glyphs, especially in the web build."},{"title":"Dimensions","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#dimensions","text":"Dimensions Dimensions combine measured geometry with a dimension style. Associative dimensions update when their source geometry changes; non-associative or partially associated dimensions require manual verification."},{"title":"Annotation scale","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#annotation-scale","text":"Annotation scale Annotative objects can display differently at different viewport scales. Confirm the active annotation scale before creating annotations and inspect all paper-space viewports before plotting."},{"title":"Layouts and viewports","chapter":"Layouts and Plotting","url":"layouts-plotting.html#layouts-and-viewports","text":"Layouts and viewports Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes."},{"title":"Page setup and plot preview","chapter":"Layouts and Plotting","url":"layouts-plotting.html#page-setup-and-plot-preview","text":"Page setup and plot preview Choose the target device or file format, paper size, orientation, plot area, scale, style behavior, and output quality. Use preview to catch clipping, incorrect line weights, hidden layers, or an unintended plot area."},{"title":"Export checks","chapter":"Layouts and Plotting","url":"layouts-plotting.html#export-checks","text":"Export checks Before distributing a PDF or plotted file, compare it with the drawing at the intended sheet size and verify text, dimensions, raster content, transparency, line types, and viewport scales."},{"title":"Solids and surfaces","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#solids-and-surfaces","text":"Solids and surfaces OpenCAD Studio can create and display solid primitives and modeled features such as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for display while exact body data is retained where supported."},{"title":"Visual styles and lighting","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#visual-styles-and-lighting","text":"Visual styles and lighting Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, not the underlying object geometry. Switch to wireframe when diagnosing missing faces or material problems."},{"title":"Tessellation quality","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#tessellation-quality","text":"Tessellation quality Curves and analytic surfaces are converted into display geometry according to view and tolerance settings. A coarse display does not necessarily mean the stored curve or solid is coarse."},{"title":"Geometric constraints","chapter":"Parametric Constraints","url":"parametric-constraints.html#geometric-constraints","text":"Geometric constraints Constraints capture relationships such as coincident, horizontal, vertical, parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the degree-of-freedom and conflict indicators to understand the sketch state."},{"title":"Editing constrained geometry","chapter":"Parametric Constraints","url":"parametric-constraints.html#editing-constrained-geometry","text":"Editing constrained geometry Moving or grip-editing constrained geometry can move neighboring objects. If a solve fails or becomes redundant, inspect the constraint glyphs and conflict status before deleting geometry."},{"title":"Persistence and history limitations","chapter":"Parametric Constraints","url":"parametric-constraints.html#persistence-and-history-limitations","text":"Persistence and history limitations Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. Other applications may preserve it without interpreting it. Constraint edits and geometry changes may currently occupy adjacent history entries; verify the result after undo, redo, erase, copy, and reopen operations."},{"title":"Command line","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#command-line","text":"Command line Enter a command name or alias, then respond to its prompts. Press Escape to cancel the active command and Return to accept an available default. Exact prompt options vary by command and current selection."},{"title":"Discover commands","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#discover-commands","text":"Discover commands Use command autocomplete and the ribbon to discover available operations. Some commands are one-shot actions; others maintain state across several point, number, keyword, or object-selection inputs."},{"title":"Shortcut conventions","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#shortcut-conventions","text":"Shortcut conventions Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows."},{"title":"Automation interface","chapter":"Automation and Plugins","url":"automation-plugins.html#automation-interface","text":"Automation interface The automation interface can inspect documents, run commands, query geometry, capture the viewport, and perform batches. Callers should use request IDs and document revisions to avoid repeating or applying work to stale state."},{"title":"Plugins","chapter":"Automation and Plugins","url":"automation-plugins.html#plugins","text":"Plugins Native plugins can contribute commands and ribbon surfaces through the plugin host. Install only trusted packages that match the host API, compiler, and CAD model compatibility requirements."},{"title":"Native and web differences","chapter":"Automation and Plugins","url":"automation-plugins.html#native-and-web-differences","text":"Native and web differences The browser build uses in-memory file workflows and cannot load native plugin libraries. Some rendering, font, windowing, and parallel-processing behavior also differs from the desktop build."},{"title":"A drawing opens incorrectly","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#a-drawing-opens-incorrectly","text":"A drawing opens incorrectly Check units, extents, layers, layouts, block placement, missing references, and fonts. Reopen a copy after saving to detect serialization problems early. Keep the original file available for comparison."},{"title":"Geometry is missing or distorted","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#geometry-is-missing-or-distorted","text":"Geometry is missing or distorted Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block scale, array settings, clipping boundaries, object normals, and coordinates far from the origin."},{"title":"Rendering is incomplete","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#rendering-is-incomplete","text":"Rendering is incomplete Reduce unusually large images or material textures, simplify extreme hatch or array inputs, and compare another visual style. Record the graphics backend and the smallest drawing that reproduces the issue."},{"title":"Undo did not restore the expected state","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#undo-did-not-restore-the-expected-state","text":"Undo did not restore the expected state Stop editing and save a diagnostic copy. Try the next adjacent undo entry only when you understand what it represents. Compare the result with autosave or backup data before overwriting the original drawing."},{"title":"Report a reproducible problem","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#report-a-reproducible-problem","text":"Report a reproducible problem Include the OpenCAD Studio version, platform, native or web build, exact steps, expected and observed results, and a minimal sanitized drawing when possible. Do not publish confidential drawings or external-reference paths."}]; diff --git a/help/tools/build_help.py b/help/tools/build_help.py deleted file mode 100644 index 7a99dff2e..000000000 --- a/help/tools/build_help.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python3 -"""Build the static OpenCAD Studio HTML help system from help/manual.md.""" - -from __future__ import annotations - -import argparse -import html -import json -import re -import subprocess -import sys -from dataclasses import dataclass -from html.parser import HTMLParser -from pathlib import Path - - -HELP = Path(__file__).resolve().parents[1] -SOURCE = HELP / "manual.md" - - -@dataclass(frozen=True) -class Page: - filename: str - title: str - summary: str - - -PAGES = ( - Page("getting-started.html", "Getting Started", "Begin a drawing safely and learn how to use this manual."), - Page("files-drawings.html", "Files and Drawings", "Open, save, recover, and move drawings with their dependencies."), - Page("workspace-navigation.html", "Workspace and Navigation", "Work with spaces, views, selection, and object snaps."), - Page("create-edit-geometry.html", "Create and Edit Geometry", "Draw, modify, grip-edit, erase, and restore geometry."), - Page("layers-properties.html", "Layers and Properties", "Organize objects and control their inherited appearance."), - Page("blocks-references.html", "Blocks and References", "Create reusable content, arrays, attributes, and file references."), - Page("annotation-dimensions.html", "Annotation and Dimensions", "Add readable text, dimensions, leaders, and scaled annotation."), - Page("layouts-plotting.html", "Layouts and Plotting", "Prepare paper-space sheets and verify plotted output."), - Page("3d-modeling.html", "3D Modeling and Visualization", "Create and inspect solids, surfaces, materials, and visual styles."), - Page("parametric-constraints.html", "Parametric Constraints", "Capture geometric intent and diagnose solve or history issues."), - Page("commands-shortcuts.html", "Commands and Shortcuts", "Use prompts, autocomplete, aliases, and keyboard conventions."), - Page("automation-plugins.html", "Automation and Plugins", "Control drawings programmatically and extend the native app."), - Page("troubleshooting-recovery.html", "Troubleshooting and Recovery", "Diagnose file, geometry, rendering, and history problems."), -) - - -def slug(text: str) -> str: - value = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE) - value = re.sub(r"[\s_]+", "-", value) - return value.strip("-") - - -def split_chapters(markdown: str) -> dict[str, str]: - chapters: dict[str, list[str]] = {} - current: list[str] | None = None - for line in markdown.splitlines(): - match = re.match(r"^## (.+)$", line) - if match: - title = match.group(1).strip() - if title in chapters: - raise RuntimeError(f"Duplicate chapter: {title}") - current = [line] - chapters[title] = current - elif current is not None: - current.append(line) - return {title: "\n".join(lines) for title, lines in chapters.items()} - - -def pandoc(markdown: str) -> str: - process = subprocess.run( - ["pandoc", "--from=gfm", "--to=html5", "--wrap=none"], - input=markdown, - text=True, - capture_output=True, - check=True, - ) - return process.stdout.strip() - - -def headings(markdown: str) -> list[tuple[str, str]]: - output: list[tuple[str, str]] = [] - for line in markdown.splitlines(): - match = re.match(r"^### (.+)$", line) - if match: - title = match.group(1).strip() - output.append((slug(title), title)) - return output - - -def sidebar(current: Page | None, chapters: dict[str, str]) -> str: - parts = ['") - return "\n".join(parts) - - -def overview() -> str: - cards = "".join( - f'{html.escape(page.title)}' - f'{html.escape(page.summary)}' - for page in PAGES - ) - return f""" -
-

OpenCAD Studio User Manual

-

Find the workflow, command, or concept you need

-

Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.

-
- -

Manual chapters

-
{cards}
-""".strip() - - -def shell(page: Page | None, title: str, body: str, chapters: dict[str, str]) -> str: - previous_link = "" - next_link = "" - if page is not None: - index = PAGES.index(page) - previous = PAGES[index - 1] if index else None - following = PAGES[index + 1] if index + 1 < len(PAGES) else None - previous_link = ( - f'' - if previous else '' - ) - next_link = ( - f'' - if following else "" - ) - elif PAGES: - next_link = f'' - - return f""" - - - - - - {html.escape(title)} — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- {sidebar(page, chapters)} -
-
-

OpenCAD Studio User Manual

-

{html.escape(title)}

- {body} - - -
-
- - -""" - - -class TextExtractor(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.parts: list[str] = [] - - def handle_data(self, data: str) -> None: - if data.strip(): - self.parts.append(data.strip()) - - -def search_entries(chapters: dict[str, str]) -> list[dict[str, str]]: - entries: list[dict[str, str]] = [] - for page in PAGES: - chapter = chapters[page.title] - sections = re.split(r"(?=^### )", chapter, flags=re.MULTILINE) - for section in sections: - match = re.match(r"^### (.+)$", section.splitlines()[0] if section.splitlines() else "") - if not match: - continue - title = match.group(1).strip() - extractor = TextExtractor() - extractor.feed(pandoc(section)) - entries.append({ - "title": title, - "chapter": page.title, - "url": f"{page.filename}#{slug(title)}", - "text": " ".join(extractor.parts), - }) - return entries - - -def generated_outputs() -> dict[str, str]: - markdown = SOURCE.read_text(encoding="utf-8") - chapters = split_chapters(markdown) - expected = {page.title for page in PAGES} - if set(chapters) != expected: - raise RuntimeError( - f"Manual chapter mapping mismatch. Missing: {sorted(expected - set(chapters))}; " - f"extra: {sorted(set(chapters) - expected)}" - ) - - output = {"index.html": shell(None, "Help overview", overview(), chapters)} - for page in PAGES: - chapter_body = "\n".join(chapters[page.title].splitlines()[1:]).lstrip() - output[page.filename] = shell(page, page.title, pandoc(chapter_body), chapters) - index_json = json.dumps(search_entries(chapters), ensure_ascii=False, separators=(",", ":")) - output["search-index.js"] = f"window.OCS_HELP_INDEX = {index_json};\n" - return output - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--check", action="store_true", help="Fail if generated output is stale.") - args = parser.parse_args() - output = generated_outputs() - - if args.check: - stale = [name for name, content in output.items() if not (HELP / name).is_file() or (HELP / name).read_text(encoding="utf-8") != content] - if stale: - print("Stale generated help: " + ", ".join(stale), file=sys.stderr) - return 1 - print(f"Generated help is current ({len(output) - 1} pages plus search index).") - return 0 - - for name, content in output.items(): - (HELP / name).write_text(content, encoding="utf-8") - print(f"Generated {len(output) - 1} pages and search-index.js in {HELP}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/help/tools/check_help.py b/help/tools/check_help.py deleted file mode 100644 index 9d1eb42f2..000000000 --- a/help/tools/check_help.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -"""Validate local links, anchors, assets, context targets, and basic HTML structure.""" - -from __future__ import annotations - -import json -import sys -from html.parser import HTMLParser -from pathlib import Path -from urllib.parse import unquote - - -HELP = Path(__file__).resolve().parents[1] - - -class Document(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.ids: set[str] = set() - self.links: list[str] = [] - self.assets: list[str] = [] - self.has_main = False - self.has_title = False - self.images_without_alt = 0 - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - values = dict(attrs) - if values.get("id"): - self.ids.add(values["id"] or "") - if tag == "a" and values.get("href"): - self.links.append(values["href"] or "") - if tag in {"img", "script", "link"}: - target = values.get("src") or values.get("href") - if target: - self.assets.append(target) - if tag == "img" and "alt" not in values: - self.images_without_alt += 1 - self.has_main |= tag == "main" - self.has_title |= tag == "title" - - -def load_documents() -> dict[str, Document]: - documents: dict[str, Document] = {} - for path in sorted(HELP.glob("*.html")): - document = Document() - document.feed(path.read_text(encoding="utf-8")) - documents[path.name] = document - return documents - - -def validate_target(source: str, target: str, documents: dict[str, Document], errors: list[str]) -> None: - if target.startswith(("http:", "https:", "mailto:", "javascript:")): - return - filename, _, fragment = target.partition("#") - filename = unquote(filename) or source - destination = HELP / filename - if not destination.is_file(): - errors.append(f"{source}: missing target {target}") - return - if fragment and destination.suffix == ".html": - document = documents.get(destination.name) - if document is None or fragment not in document.ids: - errors.append(f"{source}: missing anchor {target}") - - -def main() -> int: - documents = load_documents() - errors: list[str] = [] - if not documents: - errors.append("No generated HTML pages found.") - - for name, document in documents.items(): - if not document.has_main: - errors.append(f"{name}: missing
") - if not document.has_title: - errors.append(f"{name}: missing ") - if document.images_without_alt: - errors.append(f"{name}: {document.images_without_alt} image(s) missing alt") - for target in document.links + document.assets: - validate_target(name, target, documents, errors) - - context = json.loads((HELP / "context-map.json").read_text(encoding="utf-8")) - for group in ("commands", "surfaces"): - for key, target in context.get(group, {}).items(): - validate_target(f"context-map.json:{group}.{key}", target, documents, errors) - - if errors: - print("Help validation failed:", file=sys.stderr) - for error in errors: - print(f"- {error}", file=sys.stderr) - return 1 - - links = sum(len(document.links) for document in documents.values()) - print(f"Validated {len(documents)} pages, {links} links, and {sum(len(context.get(group, {})) for group in ('commands', 'surfaces'))} context targets.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/help/troubleshooting-recovery.html b/help/troubleshooting-recovery.html deleted file mode 100644 index ea998bcfd..000000000 --- a/help/troubleshooting-recovery.html +++ /dev/null @@ -1,59 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="description" content="OpenCAD Studio user manual — Troubleshooting and Recovery"> - <title>Troubleshooting and Recovery — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Troubleshooting and Recovery

-

A drawing opens incorrectly

-

Check units, extents, layers, layouts, block placement, missing references, and fonts. Reopen a copy after saving to detect serialization problems early. Keep the original file available for comparison.

-

Geometry is missing or distorted

-

Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block scale, array settings, clipping boundaries, object normals, and coordinates far from the origin.

-

Rendering is incomplete

-

Reduce unusually large images or material textures, simplify extreme hatch or array inputs, and compare another visual style. Record the graphics backend and the smallest drawing that reproduces the issue.

-

Undo did not restore the expected state

-

Stop editing and save a diagnostic copy. Try the next adjacent undo entry only when you understand what it represents. Compare the result with autosave or backup data before overwriting the original drawing.

-

Report a reproducible problem

-

Include the OpenCAD Studio version, platform, native or web build, exact steps, expected and observed results, and a minimal sanitized drawing when possible. Do not publish confidential drawings or external-reference paths.

- - -
-
- - diff --git a/help/workspace-navigation.html b/help/workspace-navigation.html deleted file mode 100644 index ff8e00628..000000000 --- a/help/workspace-navigation.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - Workspace and Navigation — OpenCAD Studio Help - - - - - - -
- - OpenCAD Studio Help -
- - - - -
-
- -
-
-

OpenCAD Studio User Manual

-

Workspace and Navigation

-

Model space and paper space

-

Model space is the primary drawing environment. Paper-space layouts arrange views for plotting through layout viewports. Always confirm the active space before drawing or editing.

-

Pan, zoom, and orbit

-

Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. Zoom Extents is useful when the drawing appears empty or geometry is far from the expected location.

-

Multiple model viewports

-

Model viewports can show different views of the same document. Grid, snap, camera, and render mode may vary by viewport; confirm which viewport is active before entering coordinates.

-

Selection and object snaps

-

Selection identifies the objects a command will modify. Object snaps target precise geometric locations such as endpoints, midpoints, centers, and intersections. Zoom in and verify the snap marker when precision matters.

- - -
-
- - diff --git a/packaging/Info.plist b/packaging/Info.plist index 5cdebb84d..c6df654e1 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - OCS-__BUILD_STAMP__ + Open CAD Studio CFBundleExecutable OpenCADStudio CFBundleIconFile @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - OCS-__BUILD_STAMP__ + Open CAD Studio CFBundlePackageType APPL CFBundleShortVersionString diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index a1cc724d6..bbed9a20c 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -22,20 +22,13 @@ cd "$(dirname "$0")/.." TARGET=aarch64-apple-darwin DIST=dist VERSION="${VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}" -# Bundle display name (CFBundleName/CFBundleDisplayName, what Finder/Dock/the -# menu bar show) is "OCS-" rather than the fixed "Open CAD Studio", so -# a rebuilt-and-reinstalled dev build is visibly distinct from whatever was -# running before it — same idea as `OCS_BUILD_STAMP` in build.rs (the window -# title), computed independently here since this script doesn't invoke cargo -# until after this point. -BUILD_STAMP="${BUILD_STAMP:-$(date +%Y%m%d_%H%M%S)}" if [ -z "${DEVELOPER_ID:-}" ]; then DEVELOPER_ID="$(security find-identity -v -p codesigning \ | sed -n 's/.*"\(Developer ID Application: .*\)"/\1/p' | head -1)" fi [ -n "$DEVELOPER_ID" ] || { echo "No Developer ID identity found; set DEVELOPER_ID (or '-' for ad-hoc)." >&2; exit 1; } -echo "==> Version $VERSION (OCS-$BUILD_STAMP), signing as: $DEVELOPER_ID" +echo "==> Version $VERSION, signing as: $DEVELOPER_ID" echo "==> cargo build (app + thumbnailer staticlib + launcher)" cargo build --release --target "$TARGET" @@ -96,7 +89,7 @@ cp "target/$TARGET/release/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp "$DIST/AppIcon.icns" "$DIST/DWG.icns" "$DIST/DXF.icns" "$APP/Contents/Resources/" cp -R "$EXT" "$APP/Contents/PlugIns/" -sed -e "s/__VERSION__/$VERSION/g" -e "s/__BUILD_STAMP__/$BUILD_STAMP/g" packaging/Info.plist > "$APP/Contents/Info.plist" +sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist" echo "==> codesign" if [ "$DEVELOPER_ID" = "-" ]; then diff --git a/src/app/automation.rs b/src/app/automation.rs index 5796c5e61..13584bd87 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1057,7 +1057,6 @@ mod tests { "CHANGELOG", "CUI", "ALIASEDIT", - "OPTIONS", ]; for cmd in standalone { assert!( @@ -1613,4 +1612,3 @@ mod tests { assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); } } - diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 2d9d6167b..38e703ab2 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -915,16 +915,8 @@ impl OpenCADStudio { TableCellEditStart::Started } - /// Design doc §6.2 (stage 8, Phase B live inference): after a `LINE` - /// command commits `new_handle`, auto-adds a Coincident constraint for - /// every endpoint that already coincides with an existing entity's - /// point in the current scope (`sketch_constraints::infer_coincident_refs`). - /// Suppressible by holding Shift while placing the point — reuses the - /// existing `shift_down` tracking rather than the design doc's proposed - /// new `CadCommand::set_modifier` mechanism, since this dispatch site - /// already reads `self.shift_down` directly (`apply_cmd_result_inner`'s - /// Ctrl/Shift push at :392-393) and needs no per-command plumbing. - /// No-op (and no undo entry) when nothing was inferred. + /// Adds inferred Coincident constraints after a line is committed. + /// Holding Shift suppresses inference. No match means no undo entry. fn infer_and_add_coincident_constraints(&mut self, i: usize, new_handle: Handle, entity: &acadrust::EntityType) { if self.shift_down { return; @@ -939,7 +931,12 @@ impl OpenCADStudio { if pairs.is_empty() { return; } - let constraints_before = self.tabs[i].scene.sketch_constraint_set(scope).cloned(); + let constraints_before = self + .tabs[i] + .scene + .sketch_constraint_set(scope) + .cloned() + .unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope)); let mut touched: Vec = Vec::new(); for (new_ref, existing_ref) in &pairs { touched.push(new_ref.entity); @@ -948,13 +945,10 @@ impl OpenCADStudio { touched.sort(); touched.dedup(); let label = "Coincident (inferred)"; - // Mirrors `CmdResult::AddSketchConstraint`'s handler: brackets the - // add-and-solve in its own `begin_undo`/`commit_undo_delta` so any - // geometry the solve moves (the two points may be within epsilon but - // not bit-identical) is captured as an undoable `Delta`, then pushes - // the constraint-set edit itself as the separate, adjacent - // `SketchConstraints` entry stage 9 established. let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_sketch_constraints_before(scope, constraints_before); for (new_ref, existing_ref) in pairs { self.tabs[i].scene.sketch_constraint_set_mut(scope).add( crate::scene::sketch_constraints::ConstraintKind::Coincident, @@ -968,31 +962,9 @@ impl OpenCADStudio { if let Some(pd) = pending { self.commit_undo_delta(i, pd); } - self.push_sketch_constraints_history(i, label, scope, constraints_before); } - /// Design doc §6.4 (stage 11, Phase C): removes the first - /// redundant/conflicting constraint `SketchConstraintSet::conflicts` - /// (populated by `solve_scope`'s `ocs_gcs::diagnosis::classify_redundant` - /// call, stage 10) flags in the active tab's current sketch scope, and - /// re-solves. - /// - /// **Scope-down from this doc**: a full `ConflictResolverPanel` — a - /// window listing every flagged constraint by name with a cyclable live - /// preview of each candidate fix before committing — is real, standalone - /// UI work (new window state, `Message` plumbing for open/close/select/ - /// preview/commit) this pass didn't have live-UI-testing access to - /// verify (the desktop session's app focus was blocked by an unrelated - /// stuck System Settings window for the whole of this stage). What's - /// built instead is bounded but real: a status-bar pill, visible only - /// when the current scope has at least one flagged conflict, that - /// removes exactly one flagged constraint per click — "guided" in that - /// the flagged set already comes from `classify_redundant`, just without - /// per-candidate naming/preview/choice. A user with several conflicts - /// clicks it repeatedly, watching the DOF/conflict count drop, same - /// outcome as the panel's "remove and re-solve" action minus the - /// browsing UI. The full panel remains open follow-up work, not silently - /// dropped. + /// Removes and re-solves the first conflicting constraint in the active scope. pub(super) fn resolve_one_sketch_conflict(&mut self) { let i = self.active_tab; let scope = self.tabs[i].current_sketch_scope(); @@ -1002,8 +974,11 @@ impl OpenCADStudio { let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); let label = "Remove conflicting constraint"; - let constraints_before = Some(set.clone()); + let constraints_before = set.clone(); let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_sketch_constraints_before(scope, constraints_before); self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); @@ -1011,28 +986,10 @@ impl OpenCADStudio { if let Some(pd) = pending { self.commit_undo_delta(i, pd); } - self.push_sketch_constraints_history(i, label, scope, constraints_before); } - /// PARAMETERS' Apply button (`docs/named_parameters_design.md` stage 4): - /// rebuilds the active tab's `Scene::named_parameters` from the - /// editor's working buffer, reports any row that failed to validate via - /// the command line, and re-solves every scope with a constraint driven - /// by a named parameter — the entire point of a *named* parameter is - /// that editing it ripples to whatever references it, the same way - /// editing a constraint's literal value already does. - /// - /// **Deliberate scope-down, matching this project's established - /// treatment of undo-granularity questions** (design doc §5 open - /// question 3, and the sibling constraint-system project's stage 9): the - /// geometry this ripple moves is bracketed into one undo step below - /// (same `begin_undo`/`bump_entities`/`commit_undo_delta` pattern - /// `resolve_one_sketch_conflict` above uses), but the parameter *table* - /// edit itself has no undo entry of its own yet — that would need a new - /// `HistorySnapshot` variant mirroring `SketchConstraints` - /// (`push_sketch_constraints_history`). Left for a follow-up, not - /// silently dropped: undoing after an Apply undoes the geometry it - /// moved, not the parameter values themselves. + /// Rebuilds the active tab's `Scene::named_parameters` from the + /// editor's working buffer and re-solves dependent constraints. pub(super) fn apply_named_parameter_editor_rows(&mut self) { let i = self.active_tab; // A name shared by more than one row can't be resolved by picking @@ -1057,7 +1014,6 @@ impl OpenCADStudio { failed += duplicates.len(); } let param_count = table.len(); - self.tabs[i].scene.named_parameters = table; // The whole table just changed, not one isolated value, so there is // no cheaper "which handles actually moved" test worth doing here — @@ -1073,17 +1029,18 @@ impl OpenCADStudio { .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); + let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.tabs[i].dirty = true; if !touched.is_empty() { - let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); self.tabs[i].scene.bump_entities(&changes); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } } - - self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } if failed == 0 { self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); } @@ -1111,13 +1068,9 @@ impl OpenCADStudio { if touched.is_empty() { return; } - let pending = self.begin_undo(i, "Edit named parameter", touched.len(), true); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); self.tabs[i].scene.bump_entities(&changes); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } } /// Commits one field of one Parameters-section row (Properties panel) — @@ -1150,24 +1103,30 @@ impl OpenCADStudio { return Task::none(); }; - let (result, resolve_name) = match field { - ParamField::Formula => { - if typed.is_empty() || typed == current.source { - self.refresh_properties(); - return Task::none(); - } - (self.tabs[i].scene.named_parameters_mut().set(¤t.name, &typed), current.name.clone()) + match field { + ParamField::Formula if typed.is_empty() || typed == current.source => { + self.refresh_properties(); + return Task::none(); } + ParamField::Name if typed.is_empty() || typed == current.name => { + self.refresh_properties(); + return Task::none(); + } + ParamField::Name if self.tabs[i].scene.named_parameters().contains(&typed) => { + self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); + self.refresh_properties(); + return Task::none(); + } + _ => {} + } + + let pending = self.begin_undo(i, "Edit named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + + let (result, resolve_name) = match field { + ParamField::Formula => + (self.tabs[i].scene.named_parameters_mut().set(¤t.name, &typed), current.name.clone()), ParamField::Name => { - if typed.is_empty() || typed == current.name { - self.refresh_properties(); - return Task::none(); - } - if self.tabs[i].scene.named_parameters().contains(&typed) { - self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); - self.refresh_properties(); - return Task::none(); - } self.tabs[i].scene.named_parameters_mut().remove(¤t.name); let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.source); if outcome.is_err() { @@ -1178,17 +1137,49 @@ impl OpenCADStudio { }; if let Err(e) = result { + self.tabs[i].scene.take_undo_recording(); self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); self.refresh_properties(); return Task::none(); } + + if field == ParamField::Name { + let affected: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .enumerate() + .filter_map(|(index, set)| { + set.constraints + .iter() + .any(|constraint| { + matches!(&constraint.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(name)) if name == ¤t.name) + }) + .then_some(index) + }) + .collect(); + for set_index in affected { + let scope = self.tabs[i].scene.sketch_constraints[set_index].scope; + let before = self.tabs[i].scene.sketch_constraints[set_index].clone(); + self.tabs[i].scene.record_undo_sketch_constraints_before(scope, before); + for constraint in &mut self.tabs[i].scene.sketch_constraints[set_index].constraints { + if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = &mut constraint.driving_param { + if name == ¤t.name { + *name = typed.clone(); + } + } + } + } + } self.resolve_named_parameter_edit(i, &resolve_name); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } self.refresh_properties(); Task::none() } - /// Removes Parameters-section row `index` immediately — no confirmation, - /// matching AutoCAD's own Parameters Manager delete button. Any + /// Removes Parameters-section row `index` immediately. Any /// constraint that referenced it gets the same "undefined reference" /// resolve-failure treatment a formula's own bad reference already gets /// (`build_constraint`) — re-solving surfaces that rather than needing @@ -1198,8 +1189,13 @@ impl OpenCADStudio { let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else { return Task::none(); }; + let pending = self.begin_undo(i, "Delete named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); self.tabs[i].scene.named_parameters_mut().remove(&name); self.resolve_named_parameter_edit(i, &name); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } self.refresh_properties(); Task::none() } @@ -1225,7 +1221,13 @@ impl OpenCADStudio { } n += 1; }; + let pending = self.begin_undo(i, "Add named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } self.refresh_properties(); Task::none() } @@ -2540,14 +2542,29 @@ impl OpenCADStudio { self.refresh_properties(); } CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => { + if let Err(message) = self.tabs[i] + .scene + .validate_sketch_constraint(kind, &refs, driving_param.as_ref()) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Task::none(); + } let scope = self.tabs[i].current_sketch_scope(); - // Design doc §5.1/§9: captured before the add so the - // constraint-set edit itself (not just the geometry it - // triggers) is undoable — pushed as its own history entry - // below, adjacent to (not merged into) the entity delta. - let constraints_before = self.tabs[i].scene.sketch_constraint_set(scope).cloned(); + let constraints_before = self + .tabs[i] + .scene + .sketch_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + }); let touched: Vec = refs.iter().map(|r| r.entity).collect(); let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_sketch_constraints_before(scope, constraints_before); self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched .into_iter() @@ -2562,7 +2579,6 @@ impl OpenCADStudio { if let Some(pd) = pending { self.commit_undo_delta(i, pd); } - self.push_sketch_constraints_history(i, label, scope, constraints_before); } CmdResult::AddCoincidentConstraint { point_a, point_b, label } => { let scope = self.tabs[i].current_sketch_scope(); @@ -5466,8 +5482,7 @@ impl OpenCADStudio { let groups = self.clipboard_deps.groups.clone(); self.tabs[i].scene.recreate_groups(groups, &handle_map); } - // Design doc §12: a constraint entirely between pasted entities - // follows them, matching in-drawing COPY (`Scene::copy_entities`). + // Constraints wholly within the pasted selection follow it. self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map); // Incremental: `add_entity` already tessellated every pasted top-level // solid, and existing document solids are still cached — so only newly @@ -6432,18 +6447,9 @@ mod sketch_constraint_undo_tests { } } - /// Design doc §5.1/§9: adding a persistent constraint must itself be - /// undoable/redoable — not just the geometry it happens to move. This is - /// the scoped-down §5.1(a) alternative (not the §5.1(b) "live XRecord" - /// approach, which needs stage 4/save-load): the constraint-set edit is - /// pushed as its own `HistorySnapshot::SketchConstraints` entry, adjacent - /// to but not merged into the entity `Delta` the solve produced. So a - /// compound add-and-solve costs two undo/redo presses, not one — the - /// first press (topmost entry, pushed last) removes the constraint - /// record while leaving the solved geometry in place; the second - /// (the earlier `Delta` entry) reverts the geometry itself. + /// Constraint state and solved geometry share one undo/redo transaction. #[test] - fn adding_a_constraint_is_undoable_and_redoable_in_two_steps() { + fn adding_a_constraint_is_undoable_and_redoable_atomically() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); @@ -6462,9 +6468,6 @@ mod sketch_constraint_undo_tests { 1 ); - // First undo: pops the (later-pushed) SketchConstraints entry — - // removes the constraint record but leaves the already-solved - // geometry alone. app.undo_steps(1); assert_eq!( app.tabs[app.active_tab] @@ -6473,41 +6476,24 @@ mod sketch_constraint_undo_tests { .map(|s| s.constraints.len()) .unwrap_or(0), 0, - "first undo should remove the constraint record" + "undo should remove the constraint record" ); - assert!(line_angle_deg(&app, line).abs() < 1e-6, "first undo should not yet touch the solved geometry"); - - // Second undo: pops the Delta entry — reverts the geometry. - app.undo_steps(1); assert!( (line_angle_deg(&app, line) - original_angle).abs() < 1e-3, - "second undo should restore the pre-constraint angle" - ); - - // Redo replays in the same two-step order, symmetric with undo. - app.redo_steps(1); - assert!(line_angle_deg(&app, line).abs() < 1e-6, "first redo should re-level the line"); - assert_eq!( - app.tabs[app.active_tab] - .scene - .sketch_constraint_set(SketchScope::ModelSpace) - .map(|s| s.constraints.len()) - .unwrap_or(0), - 0, - "first redo should not yet restore the constraint record" + "undo should restore the pre-constraint angle" ); app.redo_steps(1); + assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line"); assert_eq!( app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1, - "second redo should restore the constraint record" + "redo should restore the constraint record" ); } - /// Design doc §6.2 (stage 8): drawing a `LINE` whose endpoint already - /// coincides with an existing entity's point should auto-add a - /// Coincident constraint — suppressible by holding Shift. + /// A line endpoint landing on an existing point infers Coincident unless + /// Shift is held. #[test] fn drawing_a_line_onto_an_existing_endpoint_infers_a_coincident_constraint() { let mut app = OpenCADStudio::new_for_test(); @@ -6556,9 +6542,7 @@ mod sketch_constraint_undo_tests { ); } - /// Design doc §6.4 (stage 11): the bounded v1 conflict resolver removes - /// one flagged constraint per call and re-solves, and the removal itself - /// is undoable/redoable via the same stage-9 mechanism. + /// Conflict resolution removes one flagged constraint and is undoable. #[test] fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() { let mut app = OpenCADStudio::new_for_test(); @@ -6587,19 +6571,7 @@ mod sketch_constraint_undo_tests { let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); assert_eq!(set.constraints.len(), 1, "one of the two duplicates should have been removed"); - // Undo restores it. The removal pushes at least its own - // `SketchConstraints` entry (stage 9's mechanism); it may or may not - // also push a geometry `Delta`, depending on whether the resolve's - // own re-solve actually moved anything (here it shouldn't — removing - // a duplicate Horizontal changes nothing already-horizontal) — so - // undo one step at a time until the constraint reappears, rather - // than asserting a fixed step count. - for _ in 0..5 { - if app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len() == 2 { - break; - } - app.undo_steps(1); - } + app.undo_steps(1); let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); } @@ -6645,6 +6617,17 @@ mod sketch_constraint_undo_tests { }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + + app.undo_steps(1); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(line)) => line.length(), + other => panic!("expected a Line, got {other:?}"), + }; + assert!((length_after_undo - 8.0).abs() < 1e-6); + + app.redo_steps(1); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(3.0)); } /// End-to-end through the actual `Message` handlers a Properties-panel @@ -6765,11 +6748,7 @@ mod sketch_constraint_undo_tests { assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); } - /// Design doc §6.1's manual Coincident UI: two picks landing on two - /// different lines' endpoints should resolve to a real `SketchRef` pair - /// and add a Coincident constraint — the same `AddCoincidentConstraint` - /// → `nearest_sketch_point` → `AddSketchConstraint` path - /// `CoincidentConstraintCommand` drives via its two `on_point` calls. + /// Two endpoint picks resolve to a Coincident constraint. #[test] fn coincident_command_resolves_two_picked_points_into_a_constraint() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 1be68627c..bf4c94eab 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -963,9 +963,7 @@ impl OpenCADStudio { } } - // ── Constraints (persistent: added to the scope's SketchConstraintSet, - // solved via Scene::bump_entities, re-solved on every later edit — - // see docs/parametric_system_design.md §6.1) ── + // ── Persistent constraints ──────────────────────────────────── "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 26e0a623d..a52763baf 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -298,10 +298,12 @@ pub fn start_allowed(cmd: &str) -> bool { | "PERF" | "CUI" | "ALIASEDIT" - | "OPTIONS" - | "OP" | "CUILOAD" | "CUIIMPORT" + // The start page already offers Options as a button, so the + // command that opens the same dialog belongs here too. + | "OPTIONS" + | "OP" ) } diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 5ae81c355..cbfad739b 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -240,30 +240,23 @@ impl OpenCADStudio { return Some(Task::done(Message::AliasEditorOpen)); } - // ── OPTIONS — app preferences dialog ──────────────────────────── - // Registered for autocomplete (`commands/mod.rs`'s "one-shot - // commands" list) but had no actual dispatch arm anywhere — the - // Start-tab welcome screen's "Options" button emits - // `Message::OptionsOpen` directly and was the only way to reach - // it. Found while live-testing the new UI-scale setting (nothing - // to do with that feature itself, just discovered along the - // way). "OP" is AutoCAD's own short alias for this command. + // ── CUI — keyboard shortcut / key-binding editor ─────────────── + "CUI" => { + return Some(Task::done(Message::ShortcutsPanelOpen)); + } + + // ── OPTIONS / OP — the preferences dialog ────────────────────── + // Both names were registered for autocomplete but never dispatched, + // so typing either one answered "Unknown command" and the dialog + // could only be reached from the button on the start page. "OPTIONS" | "OP" => { return Some(Task::done(Message::OptionsOpen)); } - // ── PARAMETERS — named-parameter/expression table editor ─────── - // `docs/named_parameters_design.md` stage 4. AutoCAD's own - // command name for the equivalent feature (Parameters Manager). "PARAMETERS" => { return Some(Task::done(Message::NamedParametersOpen)); } - // ── CUI — keyboard shortcut / key-binding editor ─────────────── - "CUI" => { - return Some(Task::done(Message::ShortcutsPanelOpen)); - } - // CUIEXPORT — write the keyboard-shortcut customizations // (the drawing-independent CUI data) to a plain "KEY COMMAND" file. "CUIEXPORT" => { diff --git a/src/app/config.rs b/src/app/config.rs index f6077621d..6d1c8f0eb 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -435,25 +435,10 @@ pub struct StartConfig { pub section: super::StartSection, } -#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct RibbonConfig { pub collapse: CollapseMode, - /// Font size (px) of the caption under a large ribbon button's icon. - pub label_font_size: i32, - /// Font size (px) of a panel's own title ("Draw", "Modify", "Layers", …) - /// shown beneath its tools. - pub group_title_font_size: i32, -} - -impl Default for RibbonConfig { - fn default() -> Self { - Self { - collapse: CollapseMode::default(), - label_font_size: 10, - group_title_font_size: 9, - } - } } impl AppConfig { diff --git a/src/app/document.rs b/src/app/document.rs index afc634527..6bdcd7329 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -661,7 +661,6 @@ pub(super) enum HistorySnapshot { // which one it actually holds. Delta(Box), ObjectVisibility(ObjectVisibilitySnapshot), - SketchConstraints(SketchConstraintsSnapshot), } impl HistorySnapshot { @@ -669,7 +668,6 @@ impl HistorySnapshot { match self { HistorySnapshot::Delta(d) => &d.label, HistorySnapshot::ObjectVisibility(v) => &v.label, - HistorySnapshot::SketchConstraints(s) => &s.label, } } @@ -699,7 +697,12 @@ impl HistorySnapshot { .iter() .map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len())) .sum::() - .saturating_mul(96), // matches HistorySnapshot::SketchConstraints's own rough per-constraint estimate + .saturating_mul(96), + ) + .saturating_add( + d.named_parameters + .as_ref() + .map_or(0, |(before, after)| before.len().saturating_add(after.len()).saturating_mul(128)), ) .saturating_add(d.label.len()), HistorySnapshot::ObjectVisibility(v) => v @@ -723,40 +726,10 @@ impl HistorySnapshot { .saturating_add(v.selected_after.len()) .saturating_mul(16) .saturating_add(v.label.len()), - HistorySnapshot::SketchConstraints(s) => s - .before - .as_ref() - .map_or(0, |set| set.constraints.len()) - .saturating_add(s.after.as_ref().map_or(0, |set| set.constraints.len())) - .saturating_mul(96) // rough per-constraint size (a handful of refs + an id/kind/f64) - .saturating_add(s.label.len()), } } } -/// Undo/redo image for one persistent-constraint edit (design doc §5.1/§9): -/// a `SketchConstraintSet` add/remove, captured as a whole-set before/after -/// pair rather than a per-constraint diff — sketches are small, so cloning -/// the whole set is cheap, and it sidesteps needing a separate "undo one -/// constraint add" vs. "undo one constraint remove" representation. Kept as -/// its own `HistorySnapshot` variant (not folded into `DeltaSnapshot`) since -/// `SketchConstraintSet` lives on `Scene`, not in the entity/object store -/// `DeltaSnapshot` already covers — see the design doc's §5.1 discussion of -/// why this needs its own path. A geometry-changing constraint add (the -/// common case) pushes this *alongside* its own `DeltaSnapshot` entry rather -/// than merged into one atomic step — two adjacent undo presses instead of -/// one, a deliberate scope-down from the design doc's recommended "live -/// XRecord" approach (§5.1(b)), which needs stage 4 (save/load) wired up -/// first; this variant needs nothing beyond what already exists. -#[derive(Clone)] -pub(super) struct SketchConstraintsSnapshot { - pub(super) scope: crate::scene::sketch_constraints::SketchScope, - /// `None` when the scope had no constraint set at all yet. - pub(super) before: Option, - pub(super) after: Option, - pub(super) label: String, -} - /// Symmetric undo/redo image for session-only object visibility. It contains /// no document entity data and therefore can never make isolation serializable. #[derive(Clone)] @@ -793,6 +766,11 @@ pub(super) struct DeltaSnapshot { /// `document.objects`, so it needs its own channel here rather than /// riding along with `entities`/`structure`. Almost always empty. pub(super) sketch_constraints: Vec, + /// Drawing-wide parameter table changed by this transaction. + pub(super) named_parameters: Option<( + crate::scene::named_parameters::ParameterTable, + crate::scene::named_parameters::ParameterTable, + )>, pub(super) label: String, } @@ -853,16 +831,8 @@ pub(super) struct ObjectEntryDelta { pub(super) after: Option, } -/// One sketch-constraint scope's before/after image within an entity -/// `DeltaSnapshot` — e.g. ERASE removing an entity's constraints along with -/// it (`Scene::refresh_sketch_constraints`'s deletion policy). Distinct from -/// [`SketchConstraintsSnapshot`]: that type is its own separate -/// `HistorySnapshot` entry for a dedicated constraint add/remove command -/// (still a deliberate two-step undo, design doc's own scope-down); this one -/// rides inside the *same* delta as the entity change that caused it, so one -/// undo restores both together. `before`/`after` are always `Some` scope -/// state (never absent) — a scope's `Vec` entry, once created, is never -/// removed, so there is no "scope didn't exist" case to represent here. +/// One sketch-constraint scope's before/after image within an entity delta. +/// This keeps constraint cleanup atomic with edits such as entity erasure. #[derive(Clone)] pub(super) struct SketchConstraintsEntryDelta { pub(super) scope: crate::scene::sketch_constraints::SketchScope, diff --git a/src/app/history.rs b/src/app/history.rs index f18b56720..a43d387e0 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,12 +1,11 @@ use super::{ document::{ DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, - PendingHistorySnapshot, SketchConstraintsEntryDelta, SketchConstraintsSnapshot, - StructureSnapshot, TableEntryDelta, + PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta, }, OpenCADStudio, }; -use crate::scene::{ChangeKind, ObjectIsolationState}; +use crate::scene::ObjectIsolationState; use acadrust::{EntityType, Handle}; use rustc_hash::{FxHashMap, FxHashSet as HashSet}; use std::sync::Arc; @@ -185,40 +184,6 @@ impl OpenCADStudio { ); } - /// Design doc §5.1/§9: makes a persistent-constraint add/remove - /// undoable. `before` is the scope's constraint set captured *before* - /// the caller's edit (by the caller, since this fn only sees "now"); - /// `after` is read fresh here, so call this once the edit is done. - pub(super) fn push_sketch_constraints_history( - &mut self, - i: usize, - label: impl Into, - scope: crate::scene::sketch_constraints::SketchScope, - before: Option, - ) { - self.finish_pending_history(i); - let after = self.tabs[i].scene.sketch_constraint_set(scope).cloned(); - self.push_undo_entry( - i, - HistorySnapshot::SketchConstraints(SketchConstraintsSnapshot { scope, before, after, label: label.into() }), - ); - } - - fn apply_sketch_constraints_state(&mut self, i: usize, snapshot: &SketchConstraintsSnapshot, undo: bool) { - let state = if undo { snapshot.before.clone() } else { snapshot.after.clone() }; - let scene = &mut self.tabs[i].scene; - let set = state.unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(snapshot.scope)); - // The restored set's geometry may be stale (e.g. redo re-adds a - // constraint whose entities moved via other edits since) — resolve - // every entity it references, same as a fresh add would. - let touched: Vec = set.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.entity)).collect(); - *scene.sketch_constraint_set_mut(snapshot.scope) = set; - if !touched.is_empty() { - let changes: Vec<(Handle, ChangeKind)> = touched.into_iter().map(|h| (h, ChangeKind::Modified)).collect(); - scene.bump_entities(&changes); - } - } - pub(super) fn push_entity_group_history( &mut self, i: usize, @@ -261,6 +226,7 @@ impl OpenCADStudio { active_layer: None, structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), sketch_constraints: Vec::new(), + named_parameters: None, label: label.into(), }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -371,6 +337,7 @@ impl OpenCADStudio { .then_some((pending.active_layer, active_layer_after)), structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)), sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -479,6 +446,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::Layers(entries)), sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -535,6 +503,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::TextStyles(entries)), sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -591,6 +560,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::DimStyles(entries)), sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -655,6 +625,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::Objects(entries)), sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -689,6 +660,7 @@ impl OpenCADStudio { object_handles, }), sketch_constraints: Vec::new(), + named_parameters: None, label: "STYLE".to_string(), }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -769,7 +741,8 @@ impl OpenCADStudio { pending.label ); } - let (entity_before, object_before, sketch_constraints_before) = rec.into_recorded_images(); + let (entity_before, object_before, sketch_constraints_before, named_parameters_before) = + rec.into_recorded_images(); let entities: Vec<(Handle, Option>, Option>)> = entity_before .into_iter() .map(|(h, before)| { @@ -803,6 +776,10 @@ impl OpenCADStudio { }) }) .collect(); + let named_parameters = named_parameters_before.and_then(|before| { + let after = self.tabs[i].scene.named_parameters().clone(); + (before != after).then_some((before, after)) + }); let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; let mut structure = pending.structure_before.map(StructureSnapshot::Full); @@ -839,6 +816,7 @@ impl OpenCADStudio { active_layer: None, structure, sketch_constraints, + named_parameters, label: pending.label, }; self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); @@ -971,6 +949,9 @@ impl OpenCADStudio { let value = if undo { &entry.before } else { &entry.after }; *scene.sketch_constraint_set_mut(entry.scope) = value.clone(); } + if let Some((before, after)) = &d.named_parameters { + scene.named_parameters = if undo { before } else { after }.clone(); + } let (sel, dirty) = if undo { (&d.selected_before, d.dirty_before) } else { @@ -1168,13 +1149,6 @@ impl OpenCADStudio { .redo_stack .push(HistorySnapshot::ObjectVisibility(v)); } - HistorySnapshot::SketchConstraints(s) => { - self.apply_sketch_constraints_state(i, &s, true); - self.tabs[i] - .history - .redo_stack - .push(HistorySnapshot::SketchConstraints(s)); - } } } self.finish_history_apply(i, had_full, layer_panel_changed, &changes); @@ -1230,13 +1204,6 @@ impl OpenCADStudio { .undo_stack .push(HistorySnapshot::ObjectVisibility(v)); } - HistorySnapshot::SketchConstraints(s) => { - self.apply_sketch_constraints_state(i, &s, false); - self.tabs[i] - .history - .undo_stack - .push(HistorySnapshot::SketchConstraints(s)); - } } } self.finish_history_apply(i, had_full, layer_panel_changed, &changes); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5d6a215e1..ea0379081 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -524,12 +524,6 @@ pub(super) struct OpenCADStudio { snap_angle_input: String, /// Model-space lineweight preview scale, in percent (25..=200). lineweight_display_scale: i32, - /// Whole-interface scale, in percent (50..=200) — drives iced's own - /// per-window `scale_factor` (`run`'s `.scale_factor(...)` hook), so it - /// resizes every panel, dialog, the ribbon, and their text uniformly - /// rather than needing a separate font-size knob threaded through each - /// panel's own view code. - ui_scale: i32, /// Isometric drafting state and active axis pair. isometric_drafting: bool, iso_plane: settings::IsoPlane, @@ -553,25 +547,13 @@ pub(super) struct OpenCADStudio { /// When true (default), the app registers itself as a .dwg/.dxf/.bak file /// handler on each launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, - /// When true, saving a drawing with sketch constraints also materializes - /// them as AutoCAD's own native `AssocNetwork`/`Assoc2dConstraintGroup` - /// object graph (`scene::dwg_native_constraints`), so AutoCAD/BricsCAD - /// recognize them as real constraints rather than an opaque OCS-only - /// XRecord. Off by default (#opt-in — adds objects/file weight every - /// user pays for save-after-save, whether or not they need AutoCAD - /// interop). A drawing that already carries this native graph (from a - /// prior save with this on, or from real AutoCAD) keeps it synced on - /// every save regardless of this setting — see - /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment; - /// this only governs whether the graph is *created* in a scope that - /// doesn't have one yet. + /// When true, saving creates native constraint objects alongside the + /// application's own persistence record. Existing native objects remain + /// synchronized regardless of this setting. pub write_dwg_native_constraints: bool, /// When true (default), a sketch constraint's viewport pill shows its - /// glyph plus a driven value or named-parameter name (e.g. "⌀ 5.000" or - /// "⌀ hole_dia"). When false, every pill shows just the bare glyph — the - /// value/name text can cover important canvas detail on a dense sketch, - /// and the glyph alone is already enough to see *that* a constraint is - /// present (`docs/parametric_system_design.md` §6.3/§7). + /// glyph plus a driven value or named-parameter name. When false, every + /// pill shows only the glyph. pub show_constraint_values: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. @@ -1066,7 +1048,7 @@ pub(super) struct OpenCADStudio { /// edited. Seeded from `command_aliases` on open, committed back on close. alias_editor_rows: Vec<(String, String)>, - // ── Named Parameters (docs/named_parameters_design.md, stage 4) ──────── + // ── Named Parameters ────────────────────────────────────────────────── /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`, /// this isn't a copy of a separate app-level store — the real state /// lives per-document at `Scene::named_parameters`; this buffer is @@ -1999,12 +1981,6 @@ pub enum Message { CursorTypeChanged(settings::CursorType), /// Set the model-space lineweight preview scale from Options. LineweightDisplayScaleChanged(i32), - /// Set the whole-interface scale (`ui_scale`) from Options. - UiScaleChanged(i32), - /// Set the ribbon's large-button icon-label font size from Options. - RibbonLabelFontSizeChanged(i32), - /// Set the ribbon's panel-title ("Draw", "Modify", …) font size from Options. - RibbonGroupTitleFontSizeChanged(i32), /// Edit the optional crosshair RGB value; blank restores automatic contrast. CrosshairColorChanged(String), /// Set the default type/version used when first saving a new drawing. @@ -2113,8 +2089,7 @@ pub enum Message { /// Register or unregister as the .dwg/.dxf handler, from Options. Same /// setting the FILEASSOC command carries. FileAssocChanged(bool), - /// Toggle writing AutoCAD-native constraint objects on save, from - /// Options. See `write_dwg_native_constraints`'s doc comment. + /// Toggle writing native constraint objects on save. WriteDwgNativeConstraintsChanged(bool), /// Toggle showing driven values/named-parameter names on constraint /// pills, from Options. See `show_constraint_values`'s doc comment. @@ -2542,12 +2517,8 @@ pub enum Message { CloseLayoutList, /// Cycle the coordinate readout mode ($COORDS): static → live → polar. CycleCoordsMode, - /// Design doc §6.4, stage 11: removes one flagged redundant/conflicting - /// constraint from the current sketch scope (a bounded v1 of the - /// "SketchXpert-style" resolver — one guided removal per click rather - /// than a candidate-list panel with a cyclable preview; see the status - /// bar's `ResolveOneSketchConflict` pill and - /// `OpenCADStudio::resolve_one_sketch_conflict` for the full rationale). + /// Removes one flagged redundant or conflicting constraint from the + /// current sketch scope. /// No-op if the scope currently has no flagged conflict. ResolveOneSketchConflict, /// Toggle the status-bar customization menu open/closed. @@ -2863,9 +2834,7 @@ pub enum Message { /// and re-solve every constraint that reads a named parameter; stays /// open. NamedParametersApply, - // ── Parameters / Constraints sections embedded in the Properties panel - // (AutoCAD-style: a live section instead of the modal above, per-row - // commit-on-submit instead of a whole-table Apply) ───────────────────── + // ── Parameters / Constraints sections embedded in Properties ────────── /// Live text of one column of parameter row `index`, keyed by its /// `ParameterTable::iter()` position — see `PropValue::ParamRow`. PropParamInput { @@ -2880,8 +2849,7 @@ pub enum Message { index: usize, field: crate::ui::window::named_parameters::ParamField, }, - /// Remove parameter row `index` immediately (no confirmation, matching - /// AutoCAD's own Parameters Manager delete button). + /// Remove parameter row `index` immediately. PropParamDelete(usize), /// Append a fresh, uniquely-named parameter to `Scene::named_parameters`. PropParamAddNew, @@ -3566,7 +3534,6 @@ impl OpenCADStudio { isolines_awaiting_regen: false, snap_angle_input: "0".to_string(), lineweight_display_scale: 100, - ui_scale: 100, isometric_drafting: false, iso_plane: settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -4180,7 +4147,6 @@ pub fn run() -> iced::Result { ..iced::Settings::default() }) .subscription(OpenCADStudio::subscription) - .scale_factor(|state: &OpenCADStudio, _window: window::Id| state.ui_scale as f32 / 100.0) .title(|state: &OpenCADStudio, window_id: window::Id| { let _ = window_id; // all dialogs are in-canvas modals now if let Some(tab) = state.tabs.get(state.active_tab) { @@ -4225,7 +4191,6 @@ pub fn run_web() -> iced::Result { OpenCADStudio::view_main, ) .subscription(OpenCADStudio::subscription) - .scale_factor(|state: &OpenCADStudio| state.ui_scale as f32 / 100.0) .title(|_state: &OpenCADStudio| { concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() }) diff --git a/src/app/properties.rs b/src/app/properties.rs index c05ee3bc2..ac9ae40f4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -406,8 +406,7 @@ impl OpenCADStudio { read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()), ], }, - // Document-wide named-parameter table (AutoCAD - // Parameters Manager equivalent), embedded here + // Document-wide named-parameter table embedded here // instead of a separate modal — see `PropValue:: // ParamRow`'s doc comment. Belongs on the no- // selection (drawing-level) page, not a per-entity diff --git a/src/app/settings.rs b/src/app/settings.rs index 5c5a47c6d..fb4e5d34f 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -221,14 +221,6 @@ pub struct UserSettings { pub crosshair_color: Option<[u8; 3]>, /// Model-space lineweight preview scale as a percentage. pub lineweight_display_scale: i32, - /// Whole-interface scale, as a percentage — every panel, dialog, the - /// ribbon, and their text, not just one widget's font size. Wired - /// straight into iced's own per-window `scale_factor` hook - /// (`OpenCADStudio::run`), so a change takes effect immediately with no - /// per-panel plumbing needed. Relies on the struct-level `#[serde(default)]` - /// (via `impl Default` below) to backfill this field for a config file - /// saved before this setting existed, same as every other field here. - pub ui_scale: i32, /// Isometric drafting changes the grid and crosshair to the active axis pair. pub isometric_drafting: bool, pub iso_plane: IsoPlane, @@ -284,13 +276,8 @@ pub struct UserSettings { /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// handler on every launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, - /// When true, saving a drawing also writes its sketch constraints as - /// AutoCAD's own native object graph (`scene::dwg_native_constraints`), - /// not just the app's own XRecord format. Off by default — opt-in, since - /// it adds file weight every save whether or not AutoCAD/BricsCAD - /// interop is needed. See `write_dwg_native_constraints` on the app - /// struct for the full contract (a drawing that already has this native - /// graph keeps it synced regardless of this setting). + /// When true, saving also writes sketch constraints as native drawing + /// objects alongside the application's own persistence record. #[serde(default)] pub write_dwg_native_constraints: bool, /// When true (default), a sketch constraint's viewport pill shows its @@ -402,7 +389,6 @@ impl Default for UserSettings { cursor_type: CursorType::Crosshair, crosshair_color: None, lineweight_display_scale: 100, - ui_scale: 100, isometric_drafting: false, iso_plane: IsoPlane::Left, snap_angle_deg: 0.0, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 185fab031..20c3547fc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -407,7 +407,6 @@ impl OpenCADStudio { cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, - ui_scale: self.ui_scale, isometric_drafting: self.isometric_drafting, iso_plane: self.iso_plane, snap_angle_deg: self.snap_angle_deg, @@ -477,7 +476,6 @@ impl OpenCADStudio { .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200); - self.ui_scale = s.ui_scale.clamp(50, 200); self.isometric_drafting = s.isometric_drafting; self.iso_plane = s.iso_plane; self.snap_angle_deg = if s.snap_angle_deg.is_finite() { @@ -836,8 +834,6 @@ impl OpenCADStudio { annotation_auto_scale: self.annotation_auto_scale, ribbon: crate::app::config::RibbonConfig { collapse: self.ribbon.collapse_mode(), - label_font_size: self.ribbon.label_font_size(), - group_title_font_size: self.ribbon.group_title_font_size(), }, plot: self.plot_dialog.clone(), shortcuts: crate::app::config::ShortcutConfig { @@ -895,9 +891,6 @@ impl OpenCADStudio { self.dock = dock; self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); self.ribbon.set_collapse_mode(cfg.ribbon.collapse); - self.ribbon.set_label_font_size(cfg.ribbon.label_font_size); - self.ribbon - .set_group_title_font_size(cfg.ribbon.group_title_font_size); self.plot_dialog = cfg.plot; self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect(); self.shortcut_bindings @@ -1449,8 +1442,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); self.tabs[i].scene.document = doc; - // Design doc §8 stage 4: read back any persisted sketch - // constraint sets right after the document is installed. + // Load persisted constraints after installing the document. self.tabs[i].scene.load_sketch_constraints_from_document(); // named_parameters_design.md stage 2: same load-time hook, // for the document-wide parameter table. @@ -1718,15 +1710,12 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; self.sync_solid_models_for_save(i); - // Design doc §8 stage 4: write any sketch constraint sets into the - // document right before it's serialized. + // Materialize constraints immediately before serialization. self.tabs[i].scene.materialize_sketch_constraints_for_save(); // named_parameters_design.md stage 2: same save-time hook, for the // document-wide parameter table. self.tabs[i].scene.materialize_named_parameters_for_save(); - // docs/dwg_constraint_compatibility_design.md: additive DWG/DXF- - // native constraint graph, alongside (not instead of) the XRecord - // above — same save-time hook. + // Keep the optional native constraint graph synchronized on save. self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6357e45d1..1370c45c6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6040,24 +6040,6 @@ impl OpenCADStudio { Task::none() } - Message::UiScaleChanged(value) => { - self.ui_scale = value.clamp(50, 200); - self.persist_settings_if_changed(); - Task::none() - } - - Message::RibbonLabelFontSizeChanged(value) => { - self.ribbon.set_label_font_size(value); - self.save_config(); - Task::none() - } - - Message::RibbonGroupTitleFontSizeChanged(value) => { - self.ribbon.set_group_title_font_size(value); - self.save_config(); - Task::none() - } - Message::CrosshairColorChanged(value) => { self.crosshair_color_input = value.clone(); if value.trim().is_empty() { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a9b4e121f..d5e87529d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1706,16 +1706,8 @@ impl OpenCADStudio { ); } } - // Live sketch-constraint re-solve (design doc §7 open question - // 6): previously constrained neighbors only snapped into place - // on grip release — this runs the same rebuild-and-solve every - // frame so they track the drag live. First touch this gesture - // captures the neighbor's original state into the grip's own - // undo tracking (mirroring how `edited_handles` itself is - // captured above) and hides it from the resident tessellation; - // every frame folds it into this frame's `edited_handles` so the - // existing mesh/hatch/wire-preview refresh below already covers - // it for free. + // Re-solve constrained neighbors on each drag frame and include + // their original state in the gesture's undo record. let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles); for (handle, _) in &solved_by_constraints { let handle = *handle; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b2f7e780e..263b60b65 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -805,9 +805,7 @@ bg={bg_ms:.1}ms n={view_count}" let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| { !cmd.needs_entity_pick() && !cmd.is_selection_gathering() }); - // Persistent sketch-constraint glyphs (design doc §6.3/§7) — model - // space only, like the constraints themselves; paper space shows - // none (out of scope per the design doc §3.1). + // Constraint glyphs are displayed in model space only. let constraint_glyphs: Vec<(iced::Point, String, bool)> = if is_paper { Vec::new() } else { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0c52aa9ac..5e586d1dd 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -265,9 +265,6 @@ impl OpenCADStudio { self.crosshair_color, &self.crosshair_color_input, self.lineweight_display_scale, - self.ui_scale, - self.ribbon.label_font_size(), - self.ribbon.group_title_font_size(), &self.model_space, &self.model_bg_input, &self.paper_bg_input, diff --git a/src/command.rs b/src/command.rs index 5225c401a..d2466e513 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1408,8 +1408,8 @@ pub enum CmdResult { ReplaceMany(Vec<(Handle, Vec)>, Vec), /// Replace several entities as one undo step while keeping the command active. ReplaceManyContinue(Vec<(Handle, Vec)>), - /// Add a persistent parametric constraint (`docs/parametric_system_design.md` - /// §6.1) to the current scope's `SketchConstraintSet` and trigger its + /// Add a persistent parametric constraint to the current scope's + /// `SketchConstraintSet` and trigger its /// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`, /// nothing here is geometry to add or replace directly — the host adds /// the constraint record, then re-solves through `Scene::bump_entities`'s @@ -1419,8 +1419,7 @@ pub enum CmdResult { kind: crate::scene::sketch_constraints::ConstraintKind, refs: Vec, /// The typed target for a dimensional kind (Distance/Angle/Radius) — - /// a literal number, or a named-parameter reference - /// (`docs/named_parameters_design.md` stage 4, recognized by + /// a literal number, or a named-parameter reference recognized by /// `DistanceConstraintCommand`/`AngleConstraintCommand:: /// on_text_input` when the typed token matches a known parameter /// name instead of parsing as a number); `None` for a purely @@ -1429,14 +1428,9 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, - /// Add a persistent Coincident constraint between whatever sub-entity - /// point resolves nearest each picked point (design doc §6.1/§6.2's - /// manual Coincident UI — `CoincidentConstraintCommand`, - /// `src/modules/draw/constrain/coincident.rs`). Like - /// `ReassociateCenterMark`, a `CadCommand` has no document access to - /// resolve a marker itself, so it hands the two raw points back for the - /// host to resolve (`sketch_constraints::nearest_sketch_point`) and, if - /// both land near a real point, build an `AddSketchConstraint` from. + /// Adds a Coincident constraint between the sub-entity points nearest + /// the two picked positions. The host resolves the raw points because + /// `CadCommand` has no document access. AddCoincidentConstraint { point_a: DVec3, point_b: DVec3, @@ -2534,14 +2528,8 @@ pub fn all_registered_command_names() -> Vec<&'static str> { mod constraint_registry_tests { use super::*; - /// Regression guard: the whole `src/modules/draw/constrain/` family - /// (17 command ids, every one of the geometric/dimensional constraint - /// commands) predated `inventory::submit!` and was invisible to - /// command-line autocomplete and the MCP `commands` listing — - /// `docs/command_reference.md`'s own generator flagged this. Each - /// constraint module now registers itself; this pins that so the gap - /// can't silently reappear (e.g. a new constraint command added - /// without its own registration). + /// Ensures every constraint command remains discoverable by autocomplete + /// and command listings. #[test] fn every_constraint_command_is_in_the_autocomplete_registry() { let names = all_registered_command_names(); diff --git a/src/entities/curve.rs b/src/entities/curve.rs index e7a229af3..f477a83b8 100644 --- a/src/entities/curve.rs +++ b/src/entities/curve.rs @@ -422,19 +422,9 @@ pub fn entity_with_lwpolyline_world_xy(entity: &EntityType) -> EntityType { } } -/// Fixed world-space tessellation angle used for on-screen wire points. -/// -/// Finer than the kernel's own `DEFAULT_ANGLE` (7.5°, 48 segments per turn): -/// 3° gives 120 segments per full turn, so a typical circle at typical zoom -/// no longer shows its polygon joints. Still a fixed world-space angle rather -/// than one adaptive to view scale, so an aggressively zoomed-in view can -/// still reveal facets — it raises the zoom threshold rather than removing -/// the ceiling. -const DISPLAY_ANGLE: f64 = std::f64::consts::PI / 60.0; - /// World-space wire points sampled by the kernel's angular policy. pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> { - curve.tessellate_angle(DISPLAY_ANGLE) + curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) } /// The snap candidates an entity's curve offers, in the two channels the diff --git a/src/modules/draw/constrain/coincident.rs b/src/modules/draw/constrain/coincident.rs index 53ad9ba0b..8f166d554 100644 --- a/src/modules/draw/constrain/coincident.rs +++ b/src/modules/draw/constrain/coincident.rs @@ -1,4 +1,4 @@ -//! CCONSTRAINT — the manual Coincident UI (design doc §6.1's open item): +//! CCONSTRAINT — manual Coincident constraint input. //! unlike the rest of this module's plain select-and-click constraints, //! Coincident addresses a *point* on an entity (an endpoint, a circle/arc //! center), not the whole entity, so it needs two point picks instead of a @@ -23,7 +23,9 @@ pub mod coincident_tool { ToolDef { id: "CCONSTRAINT", label: "Coincident", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/coincident.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/coincident.svg" + )), event: ModuleEvent::Command("CCONSTRAINT".to_string()), } } @@ -59,7 +61,11 @@ impl CadCommand for CoincidentConstraintCommand { self.first = Some(pt); CmdResult::NeedPoint } - Some(first) => CmdResult::AddCoincidentConstraint { point_a: first, point_b: pt, label: "Coincident constraint" }, + Some(first) => CmdResult::AddCoincidentConstraint { + point_a: first, + point_b: pt, + label: "Coincident constraint", + }, } } @@ -73,4 +79,6 @@ impl CadCommand for CoincidentConstraintCommand { } // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["CCONSTRAINT"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["CCONSTRAINT"] +}); diff --git a/src/modules/draw/constrain/equal_distance.rs b/src/modules/draw/constrain/equal_distance.rs index 745b21f4f..89287745b 100644 --- a/src/modules/draw/constrain/equal_distance.rs +++ b/src/modules/draw/constrain/equal_distance.rs @@ -1,6 +1,5 @@ -//! EDCONSTRAINT — AutoCAD's fourth `Equal` sub-kind -//! (`ACEQUALDISTANCECONSTRAINT`): the distance between one point pair -//! equals the distance between another, rather than two whole entities +//! EDCONSTRAINT makes the distance between one point pair equal the +//! distance between another pair, rather than comparing whole entities. //! being equal length/radius. Needs four point picks (unlike the two //! whole-entity selections `ECONSTRAINT` uses), so — like //! `CoincidentConstraintCommand` — it accumulates raw points and hands them @@ -17,7 +16,9 @@ pub mod equal_distance_tool { ToolDef { id: "EDCONSTRAINT", label: "Equal Distance", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/equal_distance.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/equal_distance.svg" + )), event: ModuleEvent::Command("EDCONSTRAINT".to_string()), } } @@ -53,8 +54,13 @@ impl CadCommand for EqualDistanceConstraintCommand { if self.points.len() < 4 { return CmdResult::NeedPoint; } - let [a, b, c, d] = self.points[..] else { unreachable!("just checked len == 4") }; - CmdResult::AddEqualDistanceConstraint { points: [a, b, c, d], label: "Equal distance constraint" } + let [a, b, c, d] = self.points[..] else { + unreachable!("just checked len == 4") + }; + CmdResult::AddEqualDistanceConstraint { + points: [a, b, c, d], + label: "Equal distance constraint", + } } fn on_enter(&mut self) -> CmdResult { @@ -67,4 +73,6 @@ impl CadCommand for EqualDistanceConstraintCommand { } // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["EDCONSTRAINT"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["EDCONSTRAINT"] +}); diff --git a/src/modules/draw/constrain/mod.rs b/src/modules/draw/constrain/mod.rs index e66832368..7c594094d 100644 --- a/src/modules/draw/constrain/mod.rs +++ b/src/modules/draw/constrain/mod.rs @@ -1,5 +1,4 @@ -//! Ribbon tools and typed-value commands for the persistent "Constraints" -//! group (design doc `docs/parametric_system_design.md` §6.1). +//! Ribbon tools and typed-value commands for persistent constraints. //! //! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`] //! to the current scope and lets `Scene::bump_entities` solve it, the same @@ -29,8 +28,11 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; -pub use point_on_entity::{center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand}; +pub use point_on_entity::{ + center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, +}; pub use tools::{ - concentric, equal, fixed, horizontal, colinear, normal, parallel, perpendicular, symmetric, tangent, vertical, + colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric, + tangent, vertical, }; pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand}; diff --git a/src/modules/draw/constrain/point_on_entity.rs b/src/modules/draw/constrain/point_on_entity.rs index f4cc523b5..c3ed411d9 100644 --- a/src/modules/draw/constrain/point_on_entity.rs +++ b/src/modules/draw/constrain/point_on_entity.rs @@ -24,7 +24,9 @@ pub mod center_point_tool { ToolDef { id: "CPCONSTRAINT", label: "Center Point", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/center_point.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/center_point.svg" + )), event: ModuleEvent::Command("CPCONSTRAINT".to_string()), } } @@ -36,7 +38,9 @@ pub mod midpoint_tool { ToolDef { id: "MPCONSTRAINT", label: "Midpoint", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/midpoint.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/midpoint.svg" + )), event: ModuleEvent::Command("MPCONSTRAINT".to_string()), } } @@ -48,7 +52,9 @@ pub mod point_on_curve_tool { ToolDef { id: "OCCONSTRAINT", label: "Point on Curve", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/point_on_curve.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/point_on_curve.svg" + )), event: ModuleEvent::Command("OCCONSTRAINT".to_string()), } } @@ -63,8 +69,18 @@ pub struct PointOnEntityConstraintCommand { } impl PointOnEntityConstraintCommand { - pub fn new(name: &'static str, kind: ConstraintKind, target: Handle, label: &'static str) -> Self { - Self { name, kind, target, label } + pub fn new( + name: &'static str, + kind: ConstraintKind, + target: Handle, + label: &'static str, + ) -> Self { + Self { + name, + kind, + target, + label, + } } } @@ -78,7 +94,12 @@ impl CadCommand for PointOnEntityConstraintCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - CmdResult::AddPointOnEntityConstraint { point: pt, target: self.target, kind: self.kind, label: self.label } + CmdResult::AddPointOnEntityConstraint { + point: pt, + target: self.target, + kind: self.kind, + label: self.label, + } } fn on_enter(&mut self) -> CmdResult { @@ -91,4 +112,6 @@ impl CadCommand for PointOnEntityConstraintCommand { } // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"] +}); diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs index 79d2777e0..0f62d0881 100644 --- a/src/modules/draw/constrain/tools.rs +++ b/src/modules/draw/constrain/tools.rs @@ -8,7 +8,9 @@ pub mod horizontal { ToolDef { id: "HCONSTRAINT", label: "Horizontal", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/horizontal.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/horizontal.svg" + )), event: ModuleEvent::Command("HCONSTRAINT".to_string()), } } @@ -20,7 +22,9 @@ pub mod vertical { ToolDef { id: "VCONSTRAINT", label: "Vertical", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/vertical.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/vertical.svg" + )), event: ModuleEvent::Command("VCONSTRAINT".to_string()), } } @@ -32,7 +36,9 @@ pub mod parallel { ToolDef { id: "PCONSTRAINT", label: "Parallel", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/parallel.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/parallel.svg" + )), event: ModuleEvent::Command("PCONSTRAINT".to_string()), } } @@ -44,7 +50,9 @@ pub mod perpendicular { ToolDef { id: "QCONSTRAINT", label: "Perpendicular", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/perpendicular.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/perpendicular.svg" + )), event: ModuleEvent::Command("QCONSTRAINT".to_string()), } } @@ -56,7 +64,9 @@ pub mod equal { ToolDef { id: "ECONSTRAINT", label: "Equal", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/equal.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/equal.svg" + )), event: ModuleEvent::Command("ECONSTRAINT".to_string()), } } @@ -68,7 +78,9 @@ pub mod tangent { ToolDef { id: "TCONSTRAINT", label: "Tangent", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/tangent.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/tangent.svg" + )), event: ModuleEvent::Command("TCONSTRAINT".to_string()), } } @@ -80,7 +92,9 @@ pub mod concentric { ToolDef { id: "NCONSTRAINT", label: "Concentric", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/concentric.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/concentric.svg" + )), event: ModuleEvent::Command("NCONSTRAINT".to_string()), } } @@ -92,15 +106,16 @@ pub mod concentric { /// always normal to its own tangent), so it's built from the same /// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line /// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only -/// covers line-to-line — AutoCAD's own `GeomConstraintType` enum lists -/// `kNormal` and `kPerpendicular` separately for exactly this reason. +/// covers line-to-line. pub mod normal { use super::*; pub fn tool() -> ToolDef { ToolDef { id: "NRCONSTRAINT", label: "Normal", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/normal.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/normal.svg" + )), event: ModuleEvent::Command("NRCONSTRAINT".to_string()), } } @@ -112,7 +127,9 @@ pub mod colinear { ToolDef { id: "LCONSTRAINT", label: "Colinear", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/colinear.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/colinear.svg" + )), event: ModuleEvent::Command("LCONSTRAINT".to_string()), } } @@ -124,7 +141,9 @@ pub mod fixed { ToolDef { id: "FXCONSTRAINT", label: "Fixed", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/fixed.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/fixed.svg" + )), event: ModuleEvent::Command("FXCONSTRAINT".to_string()), } } @@ -136,7 +155,9 @@ pub mod symmetric { ToolDef { id: "SYCONSTRAINT", label: "Symmetric", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/symmetric.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/symmetric.svg" + )), event: ModuleEvent::Command("SYCONSTRAINT".to_string()), } } diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index 8809d284f..e7262176e 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -2,8 +2,7 @@ //! (a distance or an angle), unlike the plain select-and-click constraints //! in `mod.rs`. //! -//! Persistent (design doc `docs/parametric_system_design.md` §6.1): these -//! don't solve anything themselves. A `CadCommand`'s `on_text_input` only +//! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only //! gets `&mut self` — no document access — so it can't add a //! `SketchConstraint` to the scene directly; instead these commands hand the //! typed value back as `CmdResult::AddSketchConstraint`, and the host (which @@ -24,11 +23,7 @@ use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::Scene; /// Recognizes a typed prompt token as either a numeric literal or a -/// reference to an existing named parameter (`docs/ -/// named_parameters_design.md` stage 4's "a way to pick a named parameter -/// instead of typing a literal" — the same prompt takes either, mirroring -/// how AutoCAD's own dimensional-constraint prompts accept an expression in -/// place of a bare value). `known_names` is a one-time snapshot taken at +/// reference to an existing named parameter. `known_names` is a one-time snapshot taken at /// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand /// ::new`, which already has `&Scene`) since `on_text_input` itself has no /// document access — the same constraint `default_value` already works @@ -57,7 +52,9 @@ pub mod distance_tool { ToolDef { id: "DCONSTRAINT", label: "Distance", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/distance.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/distance.svg" + )), event: ModuleEvent::Command("DCONSTRAINT".to_string()), } } @@ -69,17 +66,15 @@ pub mod angle_tool { ToolDef { id: "ACONSTRAINT", label: "Angle", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/angle.svg")), + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/angle.svg" + )), event: ModuleEvent::Command("ACONSTRAINT".to_string()), } } } -/// Which flavor of dimensional constraint the typed value ultimately -/// drives — selectable via a prompt keyword before the value itself is -/// typed, mirroring how real AutoCAD's own `DCCONSTRAINT` offers -/// `[Xdistance/Ydistance]` or `[Diameter]` as options on the same command -/// rather than as separate tools. +/// Which flavor of dimensional constraint the typed value drives. #[derive(Clone, Copy, PartialEq, Eq)] enum DistanceMode { /// Line length (two-point distance) or a circle/arc's radius — @@ -107,6 +102,8 @@ pub struct DistanceConstraintCommand { is_arc: bool, mode: DistanceMode, default_value: f64, + default_x: f64, + default_y: f64, /// The entity's current arc length, for `ArcLength` mode's `` /// — meaningless (left at 0) unless `is_arc`. default_arc_length: f64, @@ -119,49 +116,89 @@ impl DistanceConstraintCommand { /// `None` if `handle` isn't a Line, Circle, or Arc. pub fn new(scene: &Scene, handle: Handle) -> Option { let entity = scene.document.get_entity(handle)?; - let (is_circle, is_arc, default_value, default_arc_length) = match entity { - EntityType::Line(l) => { - let dx = l.end.x - l.start.x; - let dy = l.end.y - l.start.y; - (false, false, (dx * dx + dy * dy).sqrt(), 0.0) - } - EntityType::Circle(c) => (true, false, c.radius, 0.0), - EntityType::Arc(a) => (true, true, a.radius, a.arc_length()), - _ => return None, - }; - let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); - Some(Self { handle, is_circle, is_arc, mode: DistanceMode::Auto, default_value, default_arc_length, known_param_names }) + let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) = + match entity { + EntityType::Line(l) => { + let dx = l.end.x - l.start.x; + let dy = l.end.y - l.start.y; + (false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0) + } + EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0), + EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()), + _ => return None, + }; + let known_param_names = scene + .named_parameters() + .iter() + .map(|p| p.name.clone()) + .collect(); + Some(Self { + handle, + is_circle, + is_arc, + mode: DistanceMode::Auto, + default_value, + default_x, + default_y, + default_arc_length, + known_param_names, + }) } fn build(&self, target: DrivingValue) -> Option { - if let DrivingValue::Literal(v) = target { - if v <= 0.0 { + if let DrivingValue::Literal(v) = &target { + let signed_component = matches!(self.mode, DistanceMode::X | DistanceMode::Y); + if !v.is_finite() || (!signed_component && *v <= 0.0) { return None; } } let (kind, refs, label) = match (self.is_circle, self.mode) { - (true, DistanceMode::Diameter) => (ConstraintKind::Diameter, vec![SketchRef::whole(self.handle)], "Diameter constraint"), - (true, DistanceMode::ArcLength) if self.is_arc => { - (ConstraintKind::ArcLength, vec![SketchRef::whole(self.handle)], "Arc length constraint") - } - (true, _) => (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint"), + (true, DistanceMode::Diameter) => ( + ConstraintKind::Diameter, + vec![SketchRef::whole(self.handle)], + "Diameter constraint", + ), + (true, DistanceMode::ArcLength) if self.is_arc => ( + ConstraintKind::ArcLength, + vec![SketchRef::whole(self.handle)], + "Arc length constraint", + ), + (true, _) => ( + ConstraintKind::Radius, + vec![SketchRef::whole(self.handle)], + "Radius constraint", + ), (false, DistanceMode::X) => ( ConstraintKind::DistanceX, - vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + vec![ + SketchRef::point(self.handle, 0), + SketchRef::point(self.handle, 1), + ], "DistanceX constraint", ), (false, DistanceMode::Y) => ( ConstraintKind::DistanceY, - vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + vec![ + SketchRef::point(self.handle, 0), + SketchRef::point(self.handle, 1), + ], "DistanceY constraint", ), (false, _) => ( ConstraintKind::Distance, - vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)], + vec![ + SketchRef::point(self.handle, 0), + SketchRef::point(self.handle, 1), + ], "Distance constraint", ), }; - Some(CmdResult::AddSketchConstraint { kind, refs, driving_param: Some(target), label }) + Some(CmdResult::AddSketchConstraint { + kind, + refs, + driving_param: Some(target), + label, + }) } } @@ -172,15 +209,30 @@ impl CadCommand for DistanceConstraintCommand { fn prompt(&self) -> String { match (self.is_circle, self.mode) { - (true, DistanceMode::Diameter) => format!("Specify diameter <{:.4}> or [Radius]: ", self.default_value * 2.0), + (true, DistanceMode::Diameter) => format!( + "Specify diameter <{:.4}> or [Radius]: ", + self.default_value * 2.0 + ), (true, DistanceMode::ArcLength) if self.is_arc => { - format!("Specify arc length <{:.4}> or [Radius]: ", self.default_arc_length) + format!( + "Specify arc length <{:.4}> or [Radius]: ", + self.default_arc_length + ) } - (true, _) if self.is_arc => format!("Specify distance <{:.4}> or [Diameter/Arclength]: ", self.default_value), - (true, _) => format!("Specify distance <{:.4}> or [Diameter]: ", self.default_value), - (false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_value), - (false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_value), - (false, _) => format!("Specify distance <{:.4}> or [Xdistance/Ydistance]: ", self.default_value), + (true, _) if self.is_arc => format!( + "Specify distance <{:.4}> or [Diameter/Arclength]: ", + self.default_value + ), + (true, _) => format!( + "Specify distance <{:.4}> or [Diameter]: ", + self.default_value + ), + (false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_x), + (false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_y), + (false, _) => format!( + "Specify distance <{:.4}> or [Xdistance/Ydistance]: ", + self.default_value + ), } } @@ -196,9 +248,12 @@ impl CadCommand for DistanceConstraintCommand { let default = match self.mode { DistanceMode::Diameter => self.default_value * 2.0, DistanceMode::ArcLength if self.is_arc => self.default_arc_length, + DistanceMode::X => self.default_x, + DistanceMode::Y => self.default_y, _ => self.default_value, }; - self.build(DrivingValue::Literal(default)).unwrap_or(CmdResult::Cancel) + self.build(DrivingValue::Literal(default)) + .unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { @@ -262,14 +317,29 @@ impl AngleConstraintCommand { let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x); let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x); let default_value = (a2 - a1).to_degrees(); - let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); - Some(Self { fixed_handle: fixed, moving_handle: moving, default_value, known_param_names }) + let known_param_names = scene + .named_parameters() + .iter() + .map(|p| p.name.clone()) + .collect(); + Some(Self { + fixed_handle: fixed, + moving_handle: moving, + default_value, + known_param_names, + }) } fn build(&self, target: DrivingValue) -> Option { + if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) { + return None; + } Some(CmdResult::AddSketchConstraint { kind: ConstraintKind::Angle, - refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)], + refs: vec![ + SketchRef::whole(self.fixed_handle), + SketchRef::whole(self.moving_handle), + ], driving_param: Some(target), label: "Angle constraint", }) @@ -294,7 +364,8 @@ impl CadCommand for AngleConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) + self.build(DrivingValue::Literal(self.default_value)) + .unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { @@ -308,7 +379,9 @@ impl CadCommand for AngleConstraintCommand { } // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DCONSTRAINT", "ACONSTRAINT"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["DCONSTRAINT", "ACONSTRAINT"] +}); #[cfg(test)] mod tests { @@ -317,16 +390,25 @@ mod tests { #[test] fn parse_driving_value_prefers_a_numeric_literal() { let known = vec!["hole_dia".to_string()]; - assert_eq!(parse_driving_value("12.5", &known), Some(DrivingValue::Literal(12.5))); + assert_eq!( + parse_driving_value("12.5", &known), + Some(DrivingValue::Literal(12.5)) + ); // Distance's own `build` rejects a non-positive target later; the // parse step itself accepts any number, negative included. - assert_eq!(parse_driving_value("-3", &known), Some(DrivingValue::Literal(-3.0))); + assert_eq!( + parse_driving_value("-3", &known), + Some(DrivingValue::Literal(-3.0)) + ); } #[test] fn parse_driving_value_recognizes_a_known_parameter_name() { let known = vec!["hole_dia".to_string(), "plate_len".to_string()]; - assert_eq!(parse_driving_value("hole_dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + assert_eq!( + parse_driving_value("hole_dia", &known), + Some(DrivingValue::Named("hole_dia".to_string())) + ); } #[test] @@ -342,8 +424,14 @@ mod tests { // here, so a lowercase-named parameter must still resolve — and // resolve to its canonically-cased name, not the uppercased token. let known = vec!["hole_dia".to_string()]; - assert_eq!(parse_driving_value("HOLE_DIA", &known), Some(DrivingValue::Named("hole_dia".to_string()))); - assert_eq!(parse_driving_value("Hole_Dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + assert_eq!( + parse_driving_value("HOLE_DIA", &known), + Some(DrivingValue::Named("hole_dia".to_string())) + ); + assert_eq!( + parse_driving_value("Hole_Dia", &known), + Some(DrivingValue::Named("hole_dia".to_string())) + ); } fn add_line(scene: &mut Scene) -> Handle { @@ -354,34 +442,47 @@ mod tests { } fn add_circle(scene: &mut Scene) -> Handle { - scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius( - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - 3.0, - ))) + scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + ), + )) } fn add_arc(scene: &mut Scene) -> Handle { - scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - 3.0, - 0.0, - std::f64::consts::FRAC_PI_2, - ))) + scene.add_entity(EntityType::Arc( + acadrust::entities::Arc::from_center_radius_angles( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + 0.0, + std::f64::consts::FRAC_PI_2, + ), + )) } #[test] fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { let mut scene = Scene::new(); let line = add_line(&mut scene); - let mut cmd = DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); // The keyword itself only switches mode and re-prompts — it must // not build a constraint yet. assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); - assert!(cmd.prompt().contains("X distance"), "prompt should reflect the new mode: {}", cmd.prompt()); + assert!( + cmd.prompt().contains("X distance"), + "prompt should reflect the new mode: {}", + cmd.prompt() + ); match cmd.on_text_input("10") { - Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + Some(CmdResult::AddSketchConstraint { + kind, + driving_param: Some(DrivingValue::Literal(v)), + .. + }) => { assert_eq!(kind, ConstraintKind::DistanceX); assert_eq!(v, 10.0); } @@ -389,17 +490,71 @@ mod tests { } } + #[test] + fn component_distances_accept_signed_values() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); + assert!(matches!( + cmd.on_text_input("-3"), + Some(CmdResult::AddSketchConstraint { + kind: ConstraintKind::DistanceX, + driving_param: Some(DrivingValue::Literal(-3.0)), + .. + }) + )); + } + + #[test] + fn component_distance_defaults_use_the_selected_axis() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("<8.0000>")); + assert!(matches!( + cmd.on_enter(), + CmdResult::AddSketchConstraint { + kind: ConstraintKind::DistanceY, + driving_param: Some(DrivingValue::Literal(8.0)), + .. + } + )); + } + + #[test] + fn ordinary_distances_reject_nonpositive_and_nonfinite_values() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + assert!(cmd.on_text_input("-3").is_none()); + assert!(cmd.build(DrivingValue::Literal(f64::INFINITY)).is_none()); + } + #[test] fn d_keyword_switches_a_circles_command_to_diameter_mode() { let mut scene = Scene::new(); let circle = add_circle(&mut scene); - let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint))); - assert!(cmd.prompt().contains("diameter"), "prompt should reflect Diameter mode: {}", cmd.prompt()); + assert!( + cmd.prompt().contains("diameter"), + "prompt should reflect Diameter mode: {}", + cmd.prompt() + ); match cmd.on_text_input("16") { - Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + Some(CmdResult::AddSketchConstraint { + kind, + driving_param: Some(DrivingValue::Literal(v)), + .. + }) => { assert_eq!(kind, ConstraintKind::Diameter); assert_eq!(v, 16.0); } @@ -411,10 +566,13 @@ mod tests { fn default_mode_still_builds_a_plain_radius_constraint() { let mut scene = Scene::new(); let circle = add_circle(&mut scene); - let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); match cmd.on_text_input("5") { - Some(CmdResult::AddSketchConstraint { kind, .. }) => assert_eq!(kind, ConstraintKind::Radius), + Some(CmdResult::AddSketchConstraint { kind, .. }) => { + assert_eq!(kind, ConstraintKind::Radius) + } _ => panic!("expected an AddSketchConstraint{{Radius}}"), } } @@ -423,13 +581,22 @@ mod tests { fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { let mut scene = Scene::new(); let arc = add_arc(&mut scene); - let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + let mut cmd = + DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint))); - assert!(cmd.prompt().contains("arc length"), "prompt should reflect ArcLength mode: {}", cmd.prompt()); + assert!( + cmd.prompt().contains("arc length"), + "prompt should reflect ArcLength mode: {}", + cmd.prompt() + ); match cmd.on_text_input("8") { - Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => { + Some(CmdResult::AddSketchConstraint { + kind, + driving_param: Some(DrivingValue::Literal(v)), + .. + }) => { assert_eq!(kind, ConstraintKind::ArcLength); assert_eq!(v, 8.0); } @@ -441,9 +608,14 @@ mod tests { fn arc_length_keyword_is_not_offered_for_a_plain_circle() { let mut scene = Scene::new(); let circle = add_circle(&mut scene); - let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); - assert!(!cmd.prompt().contains("Arclength"), "a plain circle has no arc length option: {}", cmd.prompt()); + assert!( + !cmd.prompt().contains("Arclength"), + "a plain circle has no arc length option: {}", + cmd.prompt() + ); // "A" isn't a recognized keyword here and doesn't parse as a // number or a named parameter either, so it's rejected outright. assert!(cmd.on_text_input("A").is_none()); @@ -453,10 +625,15 @@ mod tests { fn arc_length_default_is_the_arcs_current_swept_length() { let mut scene = Scene::new(); let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 - let mut cmd = DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + let mut cmd = + DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); cmd.on_text_input("A"); let expected = 3.0 * std::f64::consts::FRAC_PI_2; - assert!(cmd.prompt().contains(&format!("{expected:.4}")), "prompt should default to the arc's current length: {}", cmd.prompt()); + assert!( + cmd.prompt().contains(&format!("{expected:.4}")), + "prompt should default to the arc's current length: {}", + cmd.prompt() + ); } } diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 033dcdced..56b8047ab 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1,44 +1,11 @@ -//! Additive DWG/DXF-native constraint persistence -//! (`docs/dwg_constraint_compatibility_design.md`). -//! -//! Writes the `acadrust`-modeled `AssocNetwork`/`AssocAction`/ -//! `Assoc2dConstraintGroup` object graph alongside — not instead of — the -//! existing `OCS_SKETCH_CONSTRAINTS` XRecord (`sketch_persist.rs`), so a -//! saved file also carries constraints AutoCAD/BricsCAD recognize as their -//! own native objects. `sketch_persist.rs` stays the format this app reads -//! back on open; this module is one-way (write-only) for now — see the -//! design doc §6 for why (our model can carry things, like -//! `DrivingValue::Named`, the native graph doesn't perfectly round-trip -//! today) and §9 for what's confirmed vs. still unverified against real -//! AutoCAD/BricsCAD (nothing here has been opened in either — verification -//! so far is a real DWG+DXF round trip through `acadrust`'s own reader, -//! which confirms self-consistency, not real-world compatibility). -//! -//! **DWG is the only format this graph round-trips through with its -//! dependency chain intact.** Confirmed by reading `acadrust`'s own DXF -//! writer (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`, -//! consulted from its `ObjectType::Associative` write arm): it deliberately -//! does not write `AssocDependency`/`AssocValueDependency`/ -//! `AssocGeomDependency`/`AssocVariable` objects to DXF at all -//! ("Unrestorable associative-framework objects are not written") — DWG has -//! no equivalent filter. A DXF save still keeps the -//! `Assoc2dConstraintGroup`/`AssocNetwork` shell and every constraint node's -//! own embedded geometry (real coordinates, written directly on the node — -//! not lost), but each node's `geometry_dependency`/`value_dependency` -//! handle points at nothing once reloaded, and no `AssocVariable` survives. -//! This module still materializes the same graph for both formats rather -//! than special-casing DXF to write less: the shell that does survive is -//! strictly more than nothing, dangling handles are an ordinary, tolerated -//! pattern in both formats (e.g. a reference to a purged object), and -//! working around an upstream limitation by patching `acadrust` is out of -//! scope (this project's stated preference — see `named_parameters_persist.rs`). -//! See the test module for the DWG-vs-DXF difference demonstrated directly. +//! Maps sketch constraints to the drawing format's native associative object graph. +//! The application record remains authoritative when both representations exist. use acadrust::objects::{ - Assoc2dConstraintGroup, AssocAction, AssocConstraintNode, - AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant, - AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency, - AssocVariable, AssociativeData, AssociativeObject, ObjectType, + Assoc2dConstraintGroup, AssocAction, AssocConstraintNode, AssocConstraintNodeData, + AssocDependency, AssocEvalValue, AssocEvalVariant, AssocGeomDependency, AssocNetwork, + AssocPersistentSubentId, AssocValueDependency, AssocVariable, AssociativeData, + AssociativeObject, ObjectType, }; use acadrust::types::{Handle, Vector3}; use acadrust::{CadDocument, EntityType}; @@ -48,17 +15,14 @@ use super::named_parameters::{DrivingValue, ParameterTable}; use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; use super::Scene; -/// The default sub-dictionary key `AcDbAssocNetwork::getInstanceFromObject` -/// uses when none is given — confirmed from Autodesk's own ObjectARX -/// reference (design doc §2b/§7.1), not guessed. +/// Default sub-dictionary key used for an associative network. const NETWORK_DICTIONARY_KEY: &str = "ACAD_ASSOCNETWORK"; /// `AcConstraintGroupNode::GroupNodeId::kNullGroupNodeId` — node id 0 is /// reserved as "no node", so real node ids start at 1. const FIRST_NODE_ID: i32 = 1; -/// `AcDb::ImplicitPointType` (design doc §4/§7.2) — confirmed from -/// Autodesk's live ObjectARX reference. +/// Native implicit-point type values. mod implicit_point_type { pub const START: u8 = 0; pub const END: u8 = 1; @@ -130,7 +94,7 @@ impl<'a> GroupBuilder<'a> { /// `is_implied`/`is_active` instead of `geometry_dependency`/ /// `geometry_node_id`, i.e. they don't obviously look like the same /// "geometry dependency for an entity reference" role) — without - /// confirming that against a real AutoCAD-written file, guessing would + /// confirming that against a real externally written file, guessing would /// risk writing a wrong-but-plausible-looking object graph, which is /// worse than the honest "not persisted to DWG yet" gap this already /// degrades to (same contract as `ArcLength`, which has no native @@ -165,7 +129,8 @@ impl<'a> GroupBuilder<'a> { }; } EntityType::Circle(c) => { - let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); + let (axis_x, _axis_y) = + crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); self.push_node( node_id, "ACCONSTRAINEDCIRCLE", @@ -183,7 +148,8 @@ impl<'a> GroupBuilder<'a> { ); } EntityType::Arc(a) => { - let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((a.normal.x, a.normal.y, a.normal.z)); + let (axis_x, _axis_y) = + crate::scene::view::transform::ocs_axes((a.normal.x, a.normal.y, a.normal.z)); let direction = Vector3::new(axis_x.0, axis_x.1, axis_x.2); let start_point = a.center + Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.start_angle.cos()) @@ -218,12 +184,14 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } - /// The `ImplicitPoint` node id for one marker on `entity` (line - /// start/end, circle/arc center — design doc §4's confirmed - /// `AcDb::ImplicitPointType` mapping), creating it the first time this - /// exact (entity, marker) pair is referenced. + /// Returns the point-node id for a line endpoint or curve center, + /// creating it the first time the marker is referenced. fn point_node(&mut self, handle: Handle, marker: i32) -> Option { - if let Some(existing) = self.entities.get(&handle).and_then(|e| e.points.get(&marker)) { + if let Some(existing) = self + .entities + .get(&handle) + .and_then(|e| e.points.get(&marker)) + { return Some(*existing); } let curve_id = self.geometry_node(handle)?; @@ -246,7 +214,11 @@ impl<'a> GroupBuilder<'a> { curve_id, }, ); - self.entities.entry(handle).or_default().points.insert(marker, node_id); + self.entities + .entry(handle) + .or_default() + .points + .insert(marker, node_id); Some(node_id) } @@ -259,9 +231,7 @@ impl<'a> GroupBuilder<'a> { } } - /// Records an undirected edge (design doc's confirmed reading of - /// `AcConstraintGroupNode`: "connection between two nodes is not - /// directed") between two node ids, on both nodes' own `connections`. + /// Records an undirected edge on both nodes. fn connect(&mut self, a: i32, b: i32) { if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) { node.connections.push(b); @@ -272,9 +242,7 @@ impl<'a> GroupBuilder<'a> { } } -/// Class name for a plain `Geometrical`-shaped constraint — the -/// `is_plain_geometrical_constraint` list confirmed in -/// `dwg_stream_readers/object_reader/associative.rs` (design doc §3). +/// Class name for a plain `Geometrical`-shaped constraint. fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { match kind { ConstraintKind::Coincident => Some("ACPOINTCOINCIDENCECONSTRAINT"), @@ -282,7 +250,7 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), - // AutoCAD's own `GeomConstraintType` enum (ObjectARX's + // the native format's own `GeomConstraintType` enum (native SDK's // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` // as a distinct, real geometric-constraint kind. ConstraintKind::Normal => Some("ACNORMALCONSTRAINT"), @@ -293,17 +261,20 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Midpoint => Some("ACMIDPOINTCONSTRAINT"), ConstraintKind::PointOnCurve => Some("ACPOINTCURVECONSTRAINT"), ConstraintKind::Symmetric => Some("ACSYMMETRICCONSTRAINT"), - // Also a plain `Geometrical`-shaped node in AutoCAD's own class list + // Also a plain `Geometrical`-shaped node in the native format's own class list // (`is_plain_geometrical_constraint`) — unlike `Equal`, its class // name never branches on entity type, so it belongs here rather // than in the caller's per-entity-type dispatch. ConstraintKind::EqualDistance => Some("ACEQUALDISTANCECONSTRAINT"), - // `Equal` isn't one native kind — the line/circle split below - // (design doc §5) needs the resolved entity types, not just the - // ConstraintKind, so it's handled by the caller instead of here. - ConstraintKind::Equal | ConstraintKind::Parallel | ConstraintKind::Distance - | ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter - | ConstraintKind::DistanceX | ConstraintKind::DistanceY => None, + // `Equal` needs the resolved entity types, so the caller handles it. + ConstraintKind::Equal + | ConstraintKind::Parallel + | ConstraintKind::Distance + | ConstraintKind::Angle + | ConstraintKind::Radius + | ConstraintKind::Diameter + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY => None, // No native DWG representation exists at all: neither // `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no // ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData` @@ -316,13 +287,15 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { } /// `Diameter`/`DistanceX`/`DistanceY` reuse `Radius`'/`Distance`'s own DWG -/// class — real AutoCAD represents them as the same +/// class — native implementations represents them as the same /// `AcRadiusDiameterConstraint`/`AcDistanceConstraint` object with a /// different `RadiusDiameterConstrType`/`DirectionType` mode byte (set in /// `constraint_node` below), not a distinct native class. const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { match kind { - ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => "ACDISTANCECONSTRAINT", + ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => { + "ACDISTANCECONSTRAINT" + } ConstraintKind::Angle => "ACANGLECONSTRAINT", ConstraintKind::Radius | ConstraintKind::Diameter => "ACRADIUSDIAMETERCONSTRAINT", _ => "", @@ -355,7 +328,11 @@ fn constraint_node( builder.push_node( node_id, class_name, - AssocConstraintNodeData::Geometrical { owner_id, is_implied: false, is_active: true }, + AssocConstraintNodeData::Geometrical { + owner_id, + is_implied: false, + is_active: true, + }, ); for target in &target_refs { builder.connect(node_id, *target); @@ -370,7 +347,12 @@ fn constraint_node( builder.push_node( node_id, "ACPARALLELCONSTRAINT", - AssocConstraintNodeData::Parallel { owner_id: na, is_implied: false, is_active: true, datum_line_index: None }, + AssocConstraintNodeData::Parallel { + owner_id: na, + is_implied: false, + is_active: true, + datum_line_index: None, + }, ); builder.connect(node_id, na); builder.connect(node_id, nb); @@ -381,21 +363,35 @@ fn constraint_node( let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) && matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); - let class_name = if both_lines { "ACEQUALLENGTHCONSTRAINT" } else { "ACEQUALRADIUSCONSTRAINT" }; + let class_name = if both_lines { + "ACEQUALLENGTHCONSTRAINT" + } else { + "ACEQUALRADIUSCONSTRAINT" + }; let node_id = builder.alloc_node_id(); builder.push_node( node_id, class_name, - AssocConstraintNodeData::Geometrical { owner_id: na, is_implied: false, is_active: true }, + AssocConstraintNodeData::Geometrical { + owner_id: na, + is_implied: false, + is_active: true, + }, ); builder.connect(node_id, na); builder.connect(node_id, nb); Some(node_id) } - ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY - | ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter => { + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::Angle + | ConstraintKind::Radius + | ConstraintKind::Diameter => { let target_refs: Vec = match constraint.kind { - ConstraintKind::Radius | ConstraintKind::Diameter => vec![builder.ref_node(*refs.first()?)?], + ConstraintKind::Radius | ConstraintKind::Diameter => { + vec![builder.ref_node(*refs.first()?)?] + } _ => { let [a, b] = refs else { return None }; vec![builder.ref_node(*a)?, builder.ref_node(*b)?] @@ -415,7 +411,7 @@ fn constraint_node( distance: None, }, // X-only/Y-only: `kFixedDirection` with the fixed unit - // vector the distance is measured along — AutoCAD's own + // vector the distance is measured along — the native format's own // representation of DistanceX/DistanceY, not a distinct // constraint class. ConstraintKind::DistanceX => AssocConstraintNodeData::Distance { @@ -482,13 +478,19 @@ struct Allocator<'a> { document: &'a mut CadDocument, /// One `AssocVariable` handle per distinct named parameter actually /// referenced in this scope, created once and shared by every - /// constraint that references the same name (mirrors how AutoCAD's own + /// constraint that references the same name (mirrors how the native format's own /// variables work — one object, many dependents). variables: FxHashMap, } impl Allocator<'_> { - fn insert_associative(&mut self, owner: Handle, dxf_name: &str, cpp_class_name: &str, data: AssociativeData) -> Handle { + fn insert_associative( + &mut self, + owner: Handle, + dxf_name: &str, + cpp_class_name: &str, + data: AssociativeData, + ) -> Handle { let handle = self.document.allocate_handle(); self.insert_associative_at(handle, owner, dxf_name, cpp_class_name, data); handle @@ -499,7 +501,14 @@ impl Allocator<'_> { /// reference each other (`AssocAction::owning_network` / /// `AssocNetwork::owned_actions`) and so must both have real handles /// before either object is actually built. - fn insert_associative_at(&mut self, handle: Handle, owner: Handle, dxf_name: &str, cpp_class_name: &str, data: AssociativeData) { + fn insert_associative_at( + &mut self, + handle: Handle, + owner: Handle, + dxf_name: &str, + cpp_class_name: &str, + data: AssociativeData, + ) { let object = AssociativeObject { handle, owner, @@ -508,7 +517,9 @@ impl Allocator<'_> { data, ..Default::default() }; - self.document.objects.insert(handle, ObjectType::Associative(object)); + self.document + .objects + .insert(handle, ObjectType::Associative(object)); } fn geom_dependency(&mut self, group_handle: Handle, entity: Handle) -> Handle { @@ -527,18 +538,13 @@ impl Allocator<'_> { }, class_version: 1, enabled: true, - // Whole-entity dependency, not a sub-entity — no persistent - // subentity class name needed (design doc §7.2: the real - // point/curve addressing lives on the constraint-node data, - // not here). + // Point and curve addressing lives on constraint-node data. persistent_subent: AssocPersistentSubentId::default(), }), ) } - /// The `AssocVariable` handle for `name`, creating it once per scope. - /// `formula` is our own `ParameterTable` source string for this name — - /// `AcDbAssocVariable.expression` is exactly this shape (design doc §5). + /// Returns the `AssocVariable` handle for `name`, creating it once per scope. fn variable(&mut self, group_handle: Handle, name: &str, formula: &str) -> Handle { if let Some(&handle) = self.variables.get(name) { return handle; @@ -552,9 +558,7 @@ impl Allocator<'_> { class_version: 1, name: name.to_string(), expression: formula.to_string(), - // Left empty deliberately: "If empty, the default evaluator - // for current acad version will be used" (Autodesk's own - // AcDbAssocVariable.h, design doc §5/§7.4) — not a gap. + // Empty selects the format's default expression evaluator. evaluator: String::new(), description: String::new(), value: AssocEvalVariant::default(), @@ -568,13 +572,18 @@ impl Allocator<'_> { handle } - /// `variable_handle` is `Some` (from a prior call to [`Allocator::variable`]) - /// when this driving value is `DrivingValue::Named` — `Handle::NULL`'s - /// meaning for `AssocDependency::dependent_on` (design doc §5: "`NULL` - /// for a bare literal, or an `AssocVariable`'s handle when the value is - /// driven by a named parameter") for a plain `DrivingValue::Literal`. - fn value_dependency(&mut self, group_handle: Handle, variable_handle: Option, resolved: f64) -> Handle { - let value = AssocEvalVariant { code: 40, value: AssocEvalValue::Real(resolved) }; + /// Uses a variable dependency for named values and a null dependency for + /// literal values. + fn value_dependency( + &mut self, + group_handle: Handle, + variable_handle: Option, + resolved: f64, + ) -> Handle { + let value = AssocEvalVariant { + code: 40, + value: AssocEvalValue::Real(resolved), + }; self.insert_associative( group_handle, "ACDBASSOCVALUEDEPENDENCY", @@ -595,17 +604,22 @@ impl Allocator<'_> { } } -/// Patches a geometry node's `geometry_dependency` field — only the -/// whole-geometry variants (`BoundedLine`/`Circle`/`Arc`) carry one; -/// `ImplicitPoint` nodes stay `Handle::NULL` (design doc §7.2, confirmed -/// from Autodesk's own `AcConstrainedImplicitPoint` docs: it does not hold -/// its own `AssocGeomDependency`, only `curve_id` pointing at the geometry -/// node that does). +/// Sets the dependency on whole-geometry nodes. Implicit points retain a +/// null dependency and refer to their owning geometry node by `curve_id`. fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { match data { - AssocConstraintNodeData::BoundedLine { geometry_dependency, .. } - | AssocConstraintNodeData::Circle { geometry_dependency, .. } - | AssocConstraintNodeData::Arc { geometry_dependency, .. } => { + AssocConstraintNodeData::BoundedLine { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Circle { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Arc { + geometry_dependency, + .. + } => { *geometry_dependency = handle; } _ => {} @@ -617,9 +631,15 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { /// [`constraint_node`] defers to `needs_value_dependency`). fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { match data { - AssocConstraintNodeData::Distance { value_dependency, .. } - | AssocConstraintNodeData::Angle { value_dependency, .. } - | AssocConstraintNodeData::RadiusDiameter { value_dependency, .. } => { + AssocConstraintNodeData::Distance { + value_dependency, .. + } + | AssocConstraintNodeData::Angle { + value_dependency, .. + } + | AssocConstraintNodeData::RadiusDiameter { + value_dependency, .. + } => { *value_dependency = handle; } _ => {} @@ -679,10 +699,8 @@ fn ensure_associative_classes_registered(document: &mut CadDocument) { /// Ensures `owner`'s extension dictionary exists, returning its handle. /// `acadrust::CadDocument` only exposes creating one through -/// [`CadDocument::ensure_xrecord`] — the `xdic_by_handle` side map a -/// Dictionary-owned object would otherwise need is private to that crate -/// (design doc's documented reason this module anchors at a real entity -/// owner instead of the top-level NOD). So a throwaway XRecord is created +/// [`CadDocument::ensure_xrecord`] — the dictionary side map is private to +/// that crate. A throwaway XRecord is therefore created /// and immediately removed purely to force that wiring through the one /// public entry point that gets it right for both DWG and DXF. fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle { @@ -703,38 +721,54 @@ fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Han /// for clearing a stale `ACAD_ASSOCNETWORK` entry before a resave rebuilds /// it from scratch. fn remove_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str) { - let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) else { + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) + else { return; }; - let Some(index) = dictionary.entries.iter().position(|(name, _)| name.eq_ignore_ascii_case(key)) else { + let Some(index) = dictionary + .entries + .iter() + .position(|(name, _)| name.eq_ignore_ascii_case(key)) + else { return; }; let (_, target) = dictionary.entries.remove(index); remove_owned_recursive(document, target); } -fn set_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str, target: Handle) { - let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) else { +fn set_dictionary_entry( + document: &mut CadDocument, + dictionary_handle: Handle, + key: &str, + target: Handle, +) { + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) + else { return; }; - if let Some((_, handle)) = dictionary.entries.iter_mut().find(|(name, _)| name.eq_ignore_ascii_case(key)) { + if let Some((_, handle)) = dictionary + .entries + .iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case(key)) + { *handle = target; } else { dictionary.add_entry(key, target); } } -/// Removes `root` and every object transitively owned by it (design doc §6: -/// this module rebuilds a scope's whole native graph on every save, so the -/// previous save's objects must be fully torn down first — otherwise a -/// resave accumulates orphaned `AssocGeomDependency`/`AssocValueDependency`/ -/// `AssocVariable` objects forever instead of replacing them). +/// Removes `root` and every object transitively owned by it so rebuilding the +/// graph cannot accumulate orphaned dependency or variable objects. fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { if root.is_null() { return; } - let children: Vec = - document.objects.keys().copied().filter(|&h| document.object_owner(h) == Some(root)).collect(); + let children: Vec = document + .objects + .keys() + .copied() + .filter(|&h| document.object_owner(h) == Some(root)) + .collect(); for child in children { remove_owned_recursive(document, child); } @@ -743,7 +777,7 @@ fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { /// Whether `owner`'s scope already carries a materialized native graph — /// from an earlier save with the setting on, or from a file that came from -/// real AutoCAD/BricsCAD. Drives the "sync-if-present, create-only-if- +/// real compatible applications. Drives the "sync-if-present, create-only-if- /// enabled" rule in [`Scene::materialize_dwg_native_constraints_for_save`]: /// a scope that already has this graph keeps getting it rebuilt on every /// save regardless of the current setting, so turning the setting off never @@ -762,7 +796,12 @@ fn has_native_network(document: &CadDocument, owner: Handle) -> bool { /// One scope's worth of /// [`Scene::materialize_dwg_native_constraints_for_save`] — see that /// method's doc comment for the save-time contract this implements. -fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchConstraintSet, parameters: &ParameterTable) { +fn materialize_scope( + document: &mut CadDocument, + owner: Handle, + set: &SketchConstraintSet, + parameters: &ParameterTable, +) { let dictionary_handle = ensure_extension_dictionary(document, owner); remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY); @@ -774,9 +813,16 @@ fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchCons if !constraint.enabled { continue; } - constraint_node(&mut builder, document, constraint, &mut needs_value_dependency); + constraint_node( + &mut builder, + document, + constraint, + &mut needs_value_dependency, + ); } - let GroupBuilder { nodes, entities, .. } = builder; + let GroupBuilder { + nodes, entities, .. + } = builder; (nodes, entities) }; if nodes.is_empty() { @@ -806,24 +852,35 @@ fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchCons // reference each other, so both handles are reserved up front. let group_handle = document.allocate_handle(); let network_handle = document.allocate_handle(); - let mut allocator = Allocator { document, variables: FxHashMap::default() }; + let mut allocator = Allocator { + document, + variables: FxHashMap::default(), + }; for (entity_handle, entity_nodes) in &entities { if entity_nodes.geometry_node_id == 0 { continue; } let dep_handle = allocator.geom_dependency(group_handle, *entity_handle); - if let Some(node) = nodes.iter_mut().find(|n| n.node_id == entity_nodes.geometry_node_id) { + if let Some(node) = nodes + .iter_mut() + .find(|n| n.node_id == entity_nodes.geometry_node_id) + { set_geometry_dependency(&mut node.data, dep_handle); } } for (node_id, driving) in needs_value_dependency { let Some(driving) = driving else { continue }; - let Ok(resolved) = driving.resolve(parameters) else { continue }; + let Ok(resolved) = driving.resolve(parameters) else { + continue; + }; let variable_handle = match &driving { DrivingValue::Named(name) => { - let formula = parameters.get(name).map(|p| p.source.clone()).unwrap_or_default(); + let formula = parameters + .get(name) + .map(|p| p.source.clone()) + .unwrap_or_default(); Some(allocator.variable(group_handle, name, &formula)) } DrivingValue::Literal(_) => None, @@ -835,12 +892,13 @@ fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchCons } let group = Assoc2dConstraintGroup { - action: AssocAction { owning_network: network_handle, ..Default::default() }, + action: AssocAction { + owning_network: network_handle, + ..Default::default() + }, version: 1, flag: true, - // Identity XY plane at the world origin: every entity this module - // builds constraint nodes for already lives in world XY (design doc - // §4 — a rotated/offset sketch plane isn't modeled yet). + // Only world-XY entities reach native graph materialization. work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], dependency: Handle::NULL, actions: Vec::new(), @@ -869,23 +927,25 @@ fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchCons AssociativeData::Network(network), ); - set_dictionary_entry(allocator.document, dictionary_handle, NETWORK_DICTIONARY_KEY, network_handle); + set_dictionary_entry( + allocator.document, + dictionary_handle, + NETWORK_DICTIONARY_KEY, + network_handle, + ); } impl Scene { /// Rebuilds every sketch scope's native `AssocNetwork`/ /// `Assoc2dConstraintGroup` graph from `self.sketch_constraints`, - /// replacing whatever this module wrote on a previous save — the same - /// "materialize right before save, from scratch every time" convention - /// `sketch_persist.rs`'s `materialize_sketch_constraints_for_save` uses, - /// called alongside it, not instead of it (design doc §6: additive). + /// replacing whatever this module wrote on a previous save. /// /// `enabled` is the app's `write_dwg_native_constraints` Options toggle /// (off by default — this graph adds file weight every save, whether or - /// not AutoCAD/BricsCAD interop is wanted). It only gates *creating* the + /// not compatible applications interop is wanted). It only gates *creating* the /// graph in a scope that doesn't have one yet: a scope that already /// carries one — from an earlier save with the setting on, or from a - /// file that came from real AutoCAD — keeps getting it rebuilt in sync + /// file that came from native implementations — keeps getting it rebuilt in sync /// regardless of the current setting ([`has_native_network`]), so /// toggling this off never leaves a stale graph sitting in the file. pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { @@ -922,7 +982,10 @@ mod tests { } fn circle_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { - scene.add_entity(EntityType::Circle(Circle::from_center_radius(Vector3::new(center.0, center.1, 0.0), radius))) + scene.add_entity(EntityType::Circle(Circle::from_center_radius( + Vector3::new(center.0, center.1, 0.0), + radius, + ))) } fn arc_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { @@ -941,22 +1004,34 @@ mod tests { /// any broken link, since every test using this expects the graph to be /// present and well-formed. fn native_group(document: &CadDocument, owner: Handle) -> &Assoc2dConstraintGroup { - let dict = document.extension_dictionary_handle(owner).expect("extension dictionary should exist"); + let dict = document + .extension_dictionary_handle(owner) + .expect("extension dictionary should exist"); let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dict) else { panic!("expected owner's extension dictionary object to exist"); }; - let network_handle = - dictionary.get(NETWORK_DICTIONARY_KEY).expect("ACAD_ASSOCNETWORK entry should exist"); - let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::Network(network), .. })) = - document.objects.get(&network_handle) + let network_handle = dictionary + .get(NETWORK_DICTIONARY_KEY) + .expect("ACAD_ASSOCNETWORK entry should exist"); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(network), + .. + })) = document.objects.get(&network_handle) else { panic!("expected an AssocNetwork object at the ACAD_ASSOCNETWORK handle"); }; - let group_handle = *network.owned_actions.first().expect("network should own the constraint group"); - let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(group), .. })) = - document.objects.get(&group_handle) + let group_handle = *network + .owned_actions + .first() + .expect("network should own the constraint group"); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = document.objects.get(&group_handle) else { - panic!("expected an Assoc2dConstraintGroup object at the network's owned action handle"); + panic!( + "expected an Assoc2dConstraintGroup object at the network's owned action handle" + ); }; group } @@ -967,23 +1042,24 @@ mod tests { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(a)], - None, - ); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Vertical, - vec![SketchRef::whole(b)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; // Sanity before the round trip even happens: root + 2 geometry // nodes + 2 constraint nodes. let before = native_group(&scene.document, owner); - assert_eq!(before.nodes.len(), 5, "expected root + 2 geometry + 2 constraint nodes, got {:#?}", before.nodes); + assert_eq!( + before.nodes.len(), + 5, + "expected root + 2 geometry + 2 constraint nodes, got {:#?}", + before.nodes + ); let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext}: {e}")); @@ -991,7 +1067,8 @@ mod tests { .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded, owner); - let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); assert!( class_names.contains(&"ACHORIZONTALCONSTRAINT"), "{ext}: missing horizontal constraint node, got {class_names:?}" @@ -1001,7 +1078,10 @@ mod tests { "{ext}: missing vertical constraint node, got {class_names:?}" ); assert_eq!( - class_names.iter().filter(|&&n| n == "ACCONSTRAINEDBOUNDEDLINE").count(), + class_names + .iter() + .filter(|&&n| n == "ACCONSTRAINEDBOUNDEDLINE") + .count(), 2, "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" ); @@ -1012,12 +1092,17 @@ mod tests { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let b = line_entity(&mut scene, (20.0, 0.0), (30.0, 0.0)); - scene.named_parameters.set("gap", "5").expect("defining the parameter should succeed"); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], - Some(DrivingValue::Named("gap".to_string())), - ); + scene + .named_parameters + .set("gap", "5") + .expect("defining the parameter should succeed"); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + Some(DrivingValue::Named("gap".to_string())), + ); let owner = scene.document.header.model_space_block_handle; (scene, owner) } @@ -1040,7 +1125,10 @@ mod tests { fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { let (mut scene, owner) = distance_with_named_parameter_scene(); scene.materialize_dwg_native_constraints_for_save(true); - assert!(has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization"); + assert!( + has_variable_named(&scene.document, "gap"), + "expected an AssocVariable BEFORE serialization" + ); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .unwrap_or_else(|e| panic!("save to dwg: {e}")); @@ -1049,11 +1137,21 @@ mod tests { let group = native_group(&reloaded, owner); assert!( - group.nodes.iter().any(|n| n.class_name == "ACDISTANCECONSTRAINT"), + group + .nodes + .iter() + .any(|n| n.class_name == "ACDISTANCECONSTRAINT"), "expected an ACDISTANCECONSTRAINT node, got {:?}", - group.nodes.iter().map(|n| &n.class_name).collect::>() + group + .nodes + .iter() + .map(|n| &n.class_name) + .collect::>() + ); + assert!( + has_variable_named(&reloaded, "gap"), + "expected the AssocVariable \"gap\" to survive a DWG round trip" ); - assert!(has_variable_named(&reloaded, "gap"), "expected the AssocVariable \"gap\" to survive a DWG round trip"); } /// Confirmed by reading the actual DXF writer @@ -1084,14 +1182,20 @@ mod tests { let group = native_group(&reloaded, owner); assert!( - group.nodes.iter().any(|n| n.class_name == "ACDISTANCECONSTRAINT"), + group + .nodes + .iter() + .any(|n| n.class_name == "ACDISTANCECONSTRAINT"), "expected the ACDISTANCECONSTRAINT node's shell to survive, got {:?}", - group.nodes.iter().map(|n| &n.class_name).collect::>() + group + .nodes + .iter() + .map(|n| &n.class_name) + .collect::>() ); assert!( !has_variable_named(&reloaded, "gap"), - "acadrust's DXF writer should not carry AssocVariable — if this now passes, upstream has fixed \ - is_unrestorable_assoc_object and materialize_scope's doc comments/design doc should be updated" + "acadrust's DXF writer should not carry AssocVariable; update the native materializer if this changes" ); } @@ -1099,17 +1203,18 @@ mod tests { fn resaving_replaces_rather_than_accumulates_native_objects() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(a)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); scene.materialize_dwg_native_constraints_for_save(true); let first_count = scene.document.objects.len(); scene.materialize_dwg_native_constraints_for_save(true); let second_count = scene.document.objects.len(); - assert_eq!(first_count, second_count, "a resave with the same constraints must not accumulate new objects"); + assert_eq!( + first_count, second_count, + "a resave with the same constraints must not accumulate new objects" + ); } #[test] @@ -1131,18 +1236,19 @@ mod tests { } fn model_space_has_native_network(scene: &Scene) -> bool { - has_native_network(&scene.document, scene.document.header.model_space_block_handle) + has_native_network( + &scene.document, + scene.document.header.model_space_block_handle, + ) } #[test] fn the_setting_off_skips_a_scope_with_no_existing_native_graph() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(a)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); scene.materialize_dwg_native_constraints_for_save(false); assert!( @@ -1160,20 +1266,19 @@ mod tests { fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(a)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); scene.materialize_dwg_native_constraints_for_save(true); - assert!(model_space_has_native_network(&scene), "sanity: the graph should exist after the first, enabled save"); + assert!( + model_space_has_native_network(&scene), + "sanity: the graph should exist after the first, enabled save" + ); let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Vertical, - vec![SketchRef::whole(b)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); scene.materialize_dwg_native_constraints_for_save(false); let owner = scene.document.header.model_space_block_handle; @@ -1194,11 +1299,9 @@ mod tests { fn the_setting_off_does_not_remove_an_existing_native_graph() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(a)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); scene.materialize_dwg_native_constraints_for_save(true); scene.materialize_dwg_native_constraints_for_save(false); assert!( @@ -1225,23 +1328,56 @@ mod tests { let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Concentric, vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], None); - set.add(ConstraintKind::Colinear, vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], None); + set.add( + ConstraintKind::Concentric, + vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], + None, + ); + set.add( + ConstraintKind::Colinear, + vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], + None, + ); set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None); - set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], None); - set.add(ConstraintKind::Midpoint, vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], None); - set.add(ConstraintKind::PointOnCurve, vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], None); + set.add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], + None, + ); + set.add( + ConstraintKind::Midpoint, + vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], + None, + ); + set.add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], + None, + ); set.add( ConstraintKind::EqualDistance, - vec![SketchRef::point(line_b, 0), SketchRef::point(line_b, 1), SketchRef::point(line_a, 0), SketchRef::point(line_a, 1)], + vec![ + SketchRef::point(line_b, 0), + SketchRef::point(line_b, 1), + SketchRef::point(line_a, 0), + SketchRef::point(line_a, 1), + ], None, ); set.add( ConstraintKind::Symmetric, - vec![SketchRef::center(circle_a), SketchRef::center(circle_b), SketchRef::whole(axis)], + vec![ + SketchRef::center(circle_a), + SketchRef::center(circle_b), + SketchRef::whole(axis), + ], + None, + ); + set.add( + ConstraintKind::Normal, + vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], None, ); - set.add(ConstraintKind::Normal, vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], None); scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; @@ -1252,7 +1388,8 @@ mod tests { .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded, owner); - let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); for expected in [ "ACCONCENTRICCONSTRAINT", @@ -1265,7 +1402,10 @@ mod tests { "ACSYMMETRICCONSTRAINT", "ACNORMALCONSTRAINT", ] { - assert!(class_names.contains(&expected), "{ext}: missing {expected} node, got {class_names:?}"); + assert!( + class_names.contains(&expected), + "{ext}: missing {expected} node, got {class_names:?}" + ); } } } @@ -1286,7 +1426,11 @@ mod tests { let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Concentric, vec![SketchRef::center(arc), SketchRef::center(circle)], None); + set.add( + ConstraintKind::Concentric, + vec![SketchRef::center(arc), SketchRef::center(circle)], + None, + ); set.add( ConstraintKind::Radius, vec![SketchRef::whole(arc)], @@ -1298,14 +1442,25 @@ mod tests { let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded = crate::io::load_bytes(&format!("arc_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("arc_ref.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded, owner); - let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); - assert!(class_names.contains(&"ACCONCENTRICCONSTRAINT"), "{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}"); - assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}"); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); assert!( - group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"), + class_names.contains(&"ACCONCENTRICCONSTRAINT"), + "{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}" + ); + assert!( + class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), + "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}" + ); + assert!( + group + .nodes + .iter() + .any(|n| n.class_name == "ACCONSTRAINEDARC"), "{ext}: the arc should have its own geometry node, got {class_names:?}" ); } @@ -1325,25 +1480,34 @@ mod tests { let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0)); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded = - crate::io::load_bytes(&format!("arc_endpoint_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("arc_endpoint_ref.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded, owner); - let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); - assert!(class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), "{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}"); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); assert!( - group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"), + class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), + "{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}" + ); + assert!( + group + .nodes + .iter() + .any(|n| n.class_name == "ACCONSTRAINEDARC"), "{ext}: the arc should still have its own geometry node, got {class_names:?}" ); } @@ -1361,15 +1525,21 @@ mod tests { fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { for ext in ["dxf", "dwg"] { let mut scene = Scene::new(); - let ellipse = scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(4.0, 0.0, 0.0), - 0.5, - ))); + let ellipse = scene.add_entity(EntityType::Ellipse( + acadrust::entities::Ellipse::from_center_axes( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + 0.5, + ), + )); let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Concentric, vec![SketchRef::center(ellipse), SketchRef::center(circle)], None); + set.add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None); scene.materialize_sketch_constraints_for_save(); @@ -1378,12 +1548,16 @@ mod tests { let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded_doc = - crate::io::load_bytes(&format!("ellipse_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + let reloaded_doc = crate::io::load_bytes(&format!("ellipse_ref.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded_doc, owner); - let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); - assert!(class_names.contains(&"ACFIXEDCONSTRAINT"), "{ext}: the other constraint should still persist, got {class_names:?}"); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACFIXEDCONSTRAINT"), + "{ext}: the other constraint should still persist, got {class_names:?}" + ); assert!( !class_names.contains(&"ACCONCENTRICCONSTRAINT"), "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" @@ -1394,13 +1568,18 @@ mod tests { reloaded_scene.load_sketch_constraints_from_document(); let restored = reloaded_scene .sketch_constraint_set(SketchScope::ModelSpace) - .unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip")); + .unwrap_or_else(|| { + panic!("{ext}: no ModelSpace constraint set survived the round trip") + }); let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); - assert!(kinds.contains(&ConstraintKind::Concentric), "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}"); + assert!( + kinds.contains(&ConstraintKind::Concentric), + "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}" + ); } } - /// Constraint-parity Phase 4a: `ArcLength` has no ObjectARX class at + /// Constraint-parity Phase 4a: `ArcLength` has no native SDK class at /// all (confirmed against `AcExplicitConstr.h`) — this asserts the /// degradation is exactly as documented: the native DWG/DXF graph /// gets every *other* constraint on the arc (Radius, here) but no @@ -1414,8 +1593,16 @@ mod tests { let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(4.0))); - set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0))); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(4.0)), + ); + set.add( + ConstraintKind::ArcLength, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(6.0)), + ); // Both materializers, "called alongside, not instead of" each // other (`materialize_dwg_native_constraints_for_save`'s own @@ -1427,12 +1614,16 @@ mod tests { let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded_doc = - crate::io::load_bytes(&format!("arc_length.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + let reloaded_doc = crate::io::load_bytes(&format!("arc_length.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded_doc, owner); - let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect(); - assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}"); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), + "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}" + ); assert!( !class_names.iter().any(|n| n.contains("ARCLENGTH")), "{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}" @@ -1446,16 +1637,21 @@ mod tests { reloaded_scene.load_sketch_constraints_from_document(); let restored = reloaded_scene .sketch_constraint_set(SketchScope::ModelSpace) - .unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip")); + .unwrap_or_else(|| { + panic!("{ext}: no ModelSpace constraint set survived the round trip") + }); let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); - assert!(kinds.contains(&ConstraintKind::ArcLength), "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}"); + assert!( + kinds.contains(&ConstraintKind::ArcLength), + "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}" + ); } } /// Constraint-parity Phase 2: `Diameter`/`DistanceX`/`DistanceY` reuse /// `Radius`'/`Distance`'s own DWG class with a different /// `RadiusDiameterConstrType`/`DirectionType` mode byte, matching real - /// AutoCAD's representation (this module's own research, folded into + /// the native format's representation (this module's own research, folded into /// `dimensional_class_name`'s doc comment). Confirms that byte actually /// survives a real DWG/DXF write+read, not just the in-memory node this /// module built. @@ -1467,7 +1663,11 @@ mod tests { let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Diameter, vec![SketchRef::whole(circle)], Some(DrivingValue::Literal(16.0))); + set.add( + ConstraintKind::Diameter, + vec![SketchRef::whole(circle)], + Some(DrivingValue::Literal(16.0)), + ); set.add( ConstraintKind::DistanceX, vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], @@ -1484,8 +1684,8 @@ mod tests { let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded = - crate::io::load_bytes(&format!("diameter_directed.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("diameter_directed.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); let group = native_group(&reloaded, owner); let radius_diameter_nodes: Vec<_> = group @@ -1502,11 +1702,19 @@ mod tests { .nodes .iter() .filter_map(|n| match &n.data { - AssocConstraintNodeData::Distance { direction_type, distance, .. } => Some((*direction_type, *distance)), + AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } => Some((*direction_type, *distance)), _ => None, }) .collect(); - assert_eq!(distance_nodes.len(), 2, "{ext}: expected DistanceX and DistanceY nodes, got {distance_nodes:?}"); + assert_eq!( + distance_nodes.len(), + 2, + "{ext}: expected DistanceX and DistanceY nodes, got {distance_nodes:?}" + ); assert!( distance_nodes.contains(&(1, Some(Vector3::new(1.0, 0.0, 0.0)))), "{ext}: DistanceX should round-trip as direction_type=1 with a (1,0,0) direction, got {distance_nodes:?}" diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2b881a485..83b40fb33 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -342,6 +342,7 @@ pub struct UndoRecording { /// scope's `Vec` entry, once created, is never removed. sketch_constraints_before: HashMap, sketch_constraints_order: Vec, + named_parameters_before: Option, poisoned: bool, } @@ -351,12 +352,15 @@ impl UndoRecording { self.poisoned } - /// No entity, object, or sketch-constraint entry was recorded (nothing to undo). + /// No entity, object, constraint, or parameter state was recorded. pub fn is_empty(&self) -> bool { - self.order.is_empty() && self.object_order.is_empty() && self.sketch_constraints_order.is_empty() + self.order.is_empty() + && self.object_order.is_empty() + && self.sketch_constraints_order.is_empty() + && self.named_parameters_before.is_none() } - /// Consume all three recording directories in deterministic first-touch + /// Consume the recording directories in deterministic first-touch /// order. A `None` image means the entity/object was added by the /// command; every sketch-constraint scope's before-image is a real /// `SketchConstraintSet` (its `Vec` entry, once created, is never @@ -367,6 +371,7 @@ impl UndoRecording { Vec<(Handle, Option>)>, Vec<(Handle, Option)>, Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>, + Option, ) { let entities = self .order @@ -383,7 +388,7 @@ impl UndoRecording { .drain(..) .filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before))) .collect(); - (entities, objects, sketch_constraints) + (entities, objects, sketch_constraints, self.named_parameters_before) } /// Entity-only convenience used by the focused Scene delta tests. @@ -1925,29 +1930,11 @@ pub struct Scene { /// Conservative association hint: unknown until scanned, then updated from /// changed entities. Retaining `true` after deletion only costs an extra scan. has_associative_centers: std::cell::Cell>, - /// Persistent parametric constraint sets, one per active - /// [`sketch_constraints::SketchScope`]. Lives on `Scene` (not - /// `DocumentTab`, despite `docs/parametric_system_design.md` §2.1's - /// original suggestion) because `bump_entities` — the hook - /// `refresh_sketch_constraints` rides, per that doc's §4.1 — is a - /// `Scene` method with no access to the owning `DocumentTab`; discovered - /// while implementing that stage, not anticipated in the design doc. - /// Synced with each scope's own `OCS_SKETCH_CONSTRAINTS` XRecord around - /// open/save (design doc §8 stage 4, `sketch_persist.rs`) — the "lazy" - /// model: this `Vec` is the live in-memory state every add/remove/solve - /// touches directly, materialized into `document.objects` only right - /// before a save and read back right after a load. + /// Persistent parametric constraint sets, one per sketch scope. + /// Materialized into scoped XRecords during save and restored on open. pub(crate) sketch_constraints: Vec, - /// Document-wide named-parameter/expression table (`docs/ - /// named_parameters_design.md`). One table per document (not per-scope, - /// unlike `sketch_constraints` — a parameter is meant to be shared - /// across the whole drawing), lives on `Scene` for the same reason - /// `sketch_constraints` does: co-located with the `document: CadDocument` - /// it's synced against. Synced with a single document-level - /// `OCS_NAMED_PARAMETERS` XRecord (hung off `document.header. - /// named_objects_dict_handle`, not a per-`BlockRecord` one) around - /// open/save (`named_parameters_persist.rs`, same "lazy" model as - /// `sketch_persist.rs`). + /// Document-wide named-parameter and expression table. + /// Persisted through a single drawing XRecord. pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours @@ -2730,6 +2717,15 @@ impl Scene { } } + /// Records the drawing-wide parameter table before its first mutation in + /// the current undo transaction. + pub(crate) fn record_undo_named_parameters_before(&mut self) { + let before = self.named_parameters.clone(); + if let Some(recording) = self.undo_recording.as_mut() { + recording.named_parameters_before.get_or_insert(before); + } + } + /// Flag the open recording as touching non-entity state (a new layer, a /// group edit, a `*D` block record) that a pure-entity delta cannot /// restore. The app's per-command predicate keeps this from firing. @@ -12123,7 +12119,7 @@ mod delta_undo_tests { let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _sketch_constraints) = rec.into_recorded_images(); + let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); assert_eq!(objects.len(), 1); @@ -12147,7 +12143,7 @@ mod delta_undo_tests { scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _sketch_constraints) = rec.into_recorded_images(); + let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); assert_eq!(objects.len(), 1); diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 6d9912c80..0de4ad2e7 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -752,10 +752,7 @@ impl Scene { .map(|&h| (h, ChangeKind::Added)) .collect(); self.bump_entities(&changes); - // Design doc §12: a constraint entirely between duplicated entities - // follows them (Coincident between two copied points, Parallel - // between two copied lines, ...); one straddling a duplicated and a - // non-duplicated entity can't sensibly follow, so is left alone. + // Constraints wholly within the duplicated selection follow it. self.duplicate_sketch_constraints_for(&handle_map); self.refresh_meshes_for_handles(&refresh_solid_handles); new_handles diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs index 3168b81cd..3682af689 100644 --- a/src/scene/named_parameters.rs +++ b/src/scene/named_parameters.rs @@ -1,32 +1,5 @@ -//! Named, formula-capable document parameters — data model. -//! -//! Design: `docs/named_parameters_design.md`. This is stage 1 of that doc's -//! §4 staged plan: the parameter table type plus a small expression -//! parser/evaluator, unit-tested in isolation — no document/UI integration -//! yet (that's stage 2's XRecord persistence, mirroring -//! [`super::sketch_persist`], and stage 3's -//! `SketchConstraint::driving_param` wiring). -//! -//! A [`Parameter`] is a name plus a formula: either a bare literal (`12.5`) -//! or an expression referencing other parameters by name (`2 * hole_dia + -//! 1.5`). A [`ParameterTable`] holds every parameter for one document (per -//! the design doc's §2: one table per document, not per-[`super:: -//! sketch_constraints::SketchScope`]) and resolves each one's numeric value, -//! rejecting a circular reference at the moment it would be introduced -//! rather than at evaluation time — the design doc's explicit requirement -//! ("A referencing B referencing A must be rejected, not silently -//! infinite-loop or panic"), and the same real-time-rejection behavior a -//! spreadsheet gives for a circular formula. -//! -//! No existing workspace dependency parses expressions (the design doc's §2 -//! flags checking `evalexpr`/`meval` before hand-rolling one). Hand-rolled -//! here instead: cycle detection needs the set of names an expression -//! references *before* it's evaluated, which means owning the parse tree — -//! treating evaluation as a black box, the way a generic expression-eval -//! crate is normally used, would just mean re-deriving that same dependency -//! information by other means. [`Expr::refs`] *is* that dependency -//! information, not extra work spent duplicating what such a crate computes -//! internally anyway. +//! Named, formula-capable parameters stored per drawing. +//! Formulas are parsed locally so dependency cycles can be rejected before evaluation. use super::Scene; use serde::{Deserialize, Serialize}; @@ -34,12 +7,8 @@ use std::collections::HashMap; use std::fmt; /// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to -/// another parameter by name — what makes this a *named*-parameter system -/// rather than a plain calculator) and [`Expr::Call`] (a function call). -/// `Call` is in the grammar now so it doesn't need a rewrite if/when more -/// functions are wanted later (design doc §5 open question 2 leaves the -/// exact v1 function set undecided); only a small, conservative builtin set -/// is actually wired up for now (see `call_builtin`). +/// another parameter by name) and [`Expr::Call`] (a function call). Only the +/// small builtin set in `call_builtin` is accepted. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Expr { Number(f64), @@ -66,7 +35,11 @@ impl Expr { } } Expr::Neg(a) => a.refs(out), - Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) | Expr::Div(a, b) | Expr::Pow(a, b) => { + Expr::Add(a, b) + | Expr::Sub(a, b) + | Expr::Mul(a, b) + | Expr::Div(a, b) + | Expr::Pow(a, b) => { a.refs(out); b.refs(out); } @@ -98,7 +71,11 @@ pub enum ParamError { /// `b = a`. Cycle(Vec), UnknownFunction(String), - WrongArgCount { function: String, expected: usize, found: usize }, + WrongArgCount { + function: String, + expected: usize, + found: usize, + }, /// Raised by `resolve`/`resolve_all`, not `set` — a formula may /// legitimately reference a parameter that doesn't exist *yet* (order of /// definition shouldn't matter, mirroring a spreadsheet); this only @@ -114,8 +91,15 @@ impl fmt::Display for ParamError { ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"), ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")), ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"), - ParamError::WrongArgCount { function, expected, found } => { - write!(f, "{function}() expects {expected} argument(s), found {found}") + ParamError::WrongArgCount { + function, + expected, + found, + } => { + write!( + f, + "{function}() expects {expected} argument(s), found {found}" + ) } ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"), ParamError::DivisionByZero => write!(f, "division by zero"), @@ -125,9 +109,8 @@ impl fmt::Display for ParamError { impl std::error::Error for ParamError {} -/// The v1 builtin function set — deliberately small (design doc §5 open -/// question 2 defers the full surface). Also doubles as the reserved-word -/// list `is_reserved_name` checks a parameter name against, so a formula +/// The builtin function set. It also supplies the reserved-word list that +/// `is_reserved_name` checks a parameter name against, so a formula /// like `sqrt(x)` is never ambiguous between "call the builtin" and /// "multiply by a parameter named sqrt". const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"]; @@ -137,7 +120,7 @@ pub fn is_reserved_name(name: &str) -> bool { } /// A valid parameter name: starts with a letter or underscore, followed by -/// letters/digits/underscores. (Design doc §5 open question 5.) +/// letters, digits, or underscores. pub fn is_valid_name(name: &str) -> bool { let mut chars = name.chars(); match chars.next() { @@ -147,12 +130,7 @@ pub fn is_valid_name(name: &str) -> bool { chars.all(|c| c.is_alphanumeric() || c == '_') } -/// A dimensional constraint's driving value (design doc §2's own suggested -/// shape for `SketchConstraint::driving_param`, stage 3 of the named- -/// parameters staged plan): either a literal number typed directly, or a -/// reference to a named parameter resolved through a [`ParameterTable`] at -/// solve time. Lives here rather than in `sketch_constraints.rs` since -/// resolving `Named` needs `ParameterTable`, which this module owns. +/// A dimensional constraint's literal or named driving value. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum DrivingValue { Literal(f64), @@ -182,7 +160,11 @@ impl From for DrivingValue { fn call_builtin(name: &str, args: &[f64]) -> Result { fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> { if args.len() != expected { - return Err(ParamError::WrongArgCount { function: name.to_string(), expected, found: args.len() }); + return Err(ParamError::WrongArgCount { + function: name.to_string(), + expected, + found: args.len(), + }); } Ok(()) } @@ -302,7 +284,9 @@ fn tokenize(src: &str) -> Result, ParamError> { } } let text: String = chars[start..i].iter().collect(); - let value: f64 = text.parse().map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?; + let value: f64 = text + .parse() + .map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?; tokens.push(Token::Number(value)); } _ if c.is_alphabetic() || c == '_' => { @@ -340,8 +324,12 @@ impl<'a> Parser<'a> { fn expect(&mut self, expected: Token) -> Result<(), ParamError> { match self.bump() { Some(t) if t == expected => Ok(()), - Some(t) => Err(ParamError::Parse(format!("expected {expected:?}, found {t:?}"))), - None => Err(ParamError::Parse(format!("expected {expected:?}, found end of formula"))), + Some(t) => Err(ParamError::Parse(format!( + "expected {expected:?}, found {t:?}" + ))), + None => Err(ParamError::Parse(format!( + "expected {expected:?}, found end of formula" + ))), } } @@ -453,10 +441,16 @@ pub fn parse(source: &str) -> Result { if tokens.is_empty() { return Err(ParamError::Parse("empty formula".to_string())); } - let mut parser = Parser { tokens: &tokens, pos: 0 }; + let mut parser = Parser { + tokens: &tokens, + pos: 0, + }; let expr = parser.parse_expr()?; if parser.pos != tokens.len() { - return Err(ParamError::Parse(format!("unexpected trailing token {:?}", tokens[parser.pos]))); + return Err(ParamError::Parse(format!( + "unexpected trailing token {:?}", + tokens[parser.pos] + ))); } Ok(expr) } @@ -471,17 +465,14 @@ pub struct Parameter { pub expr: Expr, } -/// Every named parameter for one document (design doc §2: per-document, not -/// per-[`super::sketch_constraints::SketchScope`] — a block/xref does not -/// get its own independent table; open question 4 leaves whether that's the -/// final answer, but it's this stage's working assumption). +/// Every named parameter for one document. /// /// `parameters` is intentionally not `pub` (unlike the sibling /// `SketchConstraintSet::constraints`): this table's entire value is that /// every stored formula is already known to be parse-valid and cycle-free, /// an invariant only `set`/`remove` maintain. A direct external push could /// silently violate it. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ParameterTable { parameters: Vec, } @@ -523,10 +514,8 @@ impl ParameterTable { /// entry point; a future UI stage that wants add-vs-rename semantics can /// check `contains` itself first). /// - /// Rejects: an invalid identifier or one colliding with a builtin - /// function name (`is_valid_name`/`is_reserved_name`), a formula that - /// fails to parse, and — the design doc's explicit requirement — a - /// formula that would close a circular reference right now. A reference + /// Rejects an invalid or reserved identifier, malformed formula, or + /// circular reference. A reference /// to a parameter that doesn't exist *yet* is not rejected (order of /// definition shouldn't matter, mirroring a spreadsheet); it only /// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`] @@ -542,7 +531,11 @@ impl ParameterTable { self.parameters[i].source = source.to_string(); self.parameters[i].expr = expr; } - None => self.parameters.push(Parameter { name: name.to_string(), source: source.to_string(), expr }), + None => self.parameters.push(Parameter { + name: name.to_string(), + source: source.to_string(), + expr, + }), } Ok(()) } @@ -589,7 +582,12 @@ impl ParameterTable { /// Depth-first search from `current` to `target`, following each /// visited parameter's existing formula's references. Returns the path /// (starting with `current`) if `target` is reachable. - fn find_path(&self, current: &str, target: &str, visiting: &mut Vec) -> Option> { + fn find_path( + &self, + current: &str, + target: &str, + visiting: &mut Vec, + ) -> Option> { if current == target { return Some(vec![current.to_string()]); } @@ -617,12 +615,8 @@ impl ParameterTable { None } - /// Resolves one parameter's numeric value, evaluating its dependency - /// chain as needed. No state is kept between calls (a fresh memo cache - /// every call) — mirrors `refresh_sketch_constraints`'s - /// rebuild-from-scratch-per-trigger model (design doc §2): this stage - /// has no document/UI integration yet to decide when a cached value - /// would go stale, so it simply never caches across calls. + /// Resolves one parameter's numeric value and dependency chain using a + /// fresh memo cache. pub fn resolve(&self, name: &str) -> Result { let mut cache = HashMap::new(); let mut stack = Vec::new(); @@ -642,7 +636,12 @@ impl ParameterTable { out } - fn resolve_inner(&self, name: &str, cache: &mut HashMap, stack: &mut Vec) -> Result { + fn resolve_inner( + &self, + name: &str, + cache: &mut HashMap, + stack: &mut Vec, + ) -> Result { if let Some(v) = cache.get(name) { return Ok(*v); } @@ -654,7 +653,9 @@ impl ParameterTable { cyc.push(name.to_string()); return Err(ParamError::Cycle(cyc)); } - let param = self.get(name).ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?; + let param = self + .get(name) + .ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?; stack.push(name.to_string()); let value = self.eval(¶m.expr, cache, stack)?; stack.pop(); @@ -662,7 +663,12 @@ impl ParameterTable { Ok(value) } - fn eval(&self, expr: &Expr, cache: &mut HashMap, stack: &mut Vec) -> Result { + fn eval( + &self, + expr: &Expr, + cache: &mut HashMap, + stack: &mut Vec, + ) -> Result { Ok(match expr { Expr::Number(n) => *n, Expr::Ref(name) => self.resolve_inner(name, cache, stack)?, @@ -677,7 +683,9 @@ impl ParameterTable { } self.eval(a, cache, stack)? / denom } - Expr::Pow(a, b) => self.eval(a, cache, stack)?.powf(self.eval(b, cache, stack)?), + Expr::Pow(a, b) => self + .eval(a, cache, stack)? + .powf(self.eval(b, cache, stack)?), Expr::Call(fname, args) => { let mut values = Vec::with_capacity(args.len()); for arg in args { @@ -690,8 +698,8 @@ impl ParameterTable { } impl Scene { - /// The document's named-parameter table (design doc §2: one per - /// document). Read-only accessor; use [`Scene::named_parameters_mut`] to + /// The document's named-parameter table. Use + /// [`Scene::named_parameters_mut`] to /// define/redefine/remove a parameter. pub fn named_parameters(&self) -> &ParameterTable { &self.named_parameters @@ -726,7 +734,9 @@ mod tests { fn eval_literal(src: &str) -> f64 { let table = ParameterTable::new(); let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}")); - table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_or_else(|e| panic!("eval '{src}': {e}")) + table + .eval(&expr, &mut HashMap::new(), &mut Vec::new()) + .unwrap_or_else(|e| panic!("eval '{src}': {e}")) } #[test] @@ -754,11 +764,22 @@ mod tests { let table = ParameterTable::new(); let expr = parse("sqrt(1, 2)").unwrap(); - let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); - assert_eq!(err, ParamError::WrongArgCount { function: "sqrt".to_string(), expected: 1, found: 2 }); + let err = table + .eval(&expr, &mut HashMap::new(), &mut Vec::new()) + .unwrap_err(); + assert_eq!( + err, + ParamError::WrongArgCount { + function: "sqrt".to_string(), + expected: 1, + found: 2 + } + ); let expr = parse("bogus(1)").unwrap(); - let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); + let err = table + .eval(&expr, &mut HashMap::new(), &mut Vec::new()) + .unwrap_err(); assert_eq!(err, ParamError::UnknownFunction("bogus".to_string())); } @@ -766,16 +787,31 @@ mod tests { fn division_by_zero_is_reported_not_panicked() { let table = ParameterTable::new(); let expr = parse("1 / 0").unwrap(); - assert_eq!(table.eval(&expr, &mut HashMap::new(), &mut Vec::new()), Err(ParamError::DivisionByZero)); + assert_eq!( + table.eval(&expr, &mut HashMap::new(), &mut Vec::new()), + Err(ParamError::DivisionByZero) + ); } #[test] fn set_rejects_invalid_and_reserved_names() { let mut table = ParameterTable::new(); - assert!(matches!(table.set("2bad", "1"), Err(ParamError::InvalidName(_)))); - assert!(matches!(table.set("has space", "1"), Err(ParamError::InvalidName(_)))); - assert!(matches!(table.set("", "1"), Err(ParamError::InvalidName(_)))); - assert!(matches!(table.set("sqrt", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!( + table.set("2bad", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(matches!( + table.set("has space", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(matches!( + table.set("", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(matches!( + table.set("sqrt", "1"), + Err(ParamError::InvalidName(_)) + )); assert!(table.set("_valid1", "1").is_ok()); } @@ -800,7 +836,10 @@ mod tests { fn resolving_an_undefined_reference_reports_which_name_is_missing() { let mut table = ParameterTable::new(); table.set("a", "b + 1").unwrap(); - assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + assert_eq!( + table.resolve("a"), + Err(ParamError::UndefinedReference("b".to_string())) + ); } #[test] @@ -808,7 +847,10 @@ mod tests { let mut table = ParameterTable::new(); let err = table.set("a", "a + 1").unwrap_err(); assert!(matches!(err, ParamError::Cycle(_))); - assert!(!table.contains("a"), "a rejected formula must not be stored"); + assert!( + !table.contains("a"), + "a rejected formula must not be stored" + ); } #[test] @@ -816,7 +858,10 @@ mod tests { let mut table = ParameterTable::new(); table.set("a", "b").unwrap(); // b doesn't exist yet -- not a cycle, a is just unresolved for now. - assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + assert_eq!( + table.resolve("a"), + Err(ParamError::UndefinedReference("b".to_string())) + ); // Defining b = a closes the loop right here and must be rejected, // leaving b undefined rather than silently accepted. let err = table.set("b", "a").unwrap_err(); @@ -824,7 +869,10 @@ mod tests { assert!(!table.contains("b")); // a's formula (still "b") is untouched -- it stays unresolved, not // corrupted by the rejected attempt. - assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + assert_eq!( + table.resolve("a"), + Err(ParamError::UndefinedReference("b".to_string())) + ); } #[test] @@ -849,8 +897,14 @@ mod tests { let results = table.resolve_all(); assert_eq!(results.get("good"), Some(&Ok(10.0))); - assert_eq!(results.get("bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); - assert_eq!(results.get("depends_on_bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); + assert_eq!( + results.get("bad"), + Some(&Err(ParamError::UndefinedReference("missing".to_string()))) + ); + assert_eq!( + results.get("depends_on_bad"), + Some(&Err(ParamError::UndefinedReference("missing".to_string()))) + ); } #[test] @@ -859,8 +913,14 @@ mod tests { table.set("a", "1").unwrap(); table.set("b", "a + 1").unwrap(); assert!(table.remove("a")); - assert!(!table.remove("a"), "removing an already-removed name reports false"); - assert_eq!(table.resolve("b"), Err(ParamError::UndefinedReference("a".to_string()))); + assert!( + !table.remove("a"), + "removing an already-removed name reports false" + ); + assert_eq!( + table.resolve("b"), + Err(ParamError::UndefinedReference("a".to_string())) + ); } #[test] diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs index d57aa1c01..986827ccb 100644 --- a/src/scene/named_parameters_persist.rs +++ b/src/scene/named_parameters_persist.rs @@ -1,50 +1,8 @@ -//! Save/load integration for [`super::named_parameters::ParameterTable`] — -//! design doc §4 stage 2 (the XRecord persistence spike) plus enough of -//! stage 2's "wire it into open/save" to actually be useful, following -//! [`super::sketch_persist`]'s exact precedent (itself the sketch-constraint -//! project's stage 4/§5.2): the "lazy" model, where the table lives only in -//! `Scene::named_parameters` in-memory during editing and is materialized -//! into `document.objects` as one XRecord right before a save, then read -//! back right after a load. -//! -//! The one structural difference from `sketch_persist`: a -//! [`super::named_parameters::ParameterTable`] is document-wide, not -//! per-[`super::sketch_constraints::SketchScope`], so there is exactly one -//! XRecord for the whole document rather than one per scope owner. -//! -//! **Owner handle, and a real round-trip gap found while building this**: -//! the obvious choice was `document.header.named_objects_dict_handle` (the -//! document's own root Named Objects Dictionary) — `extension_dictionary_ -//! handle` in the vendored `cadcodec` source explicitly resolves an -//! extension dictionary for a `Dictionary`-type owner (`ObjectType:: -//! Dictionary(value) => value.xdictionary_handle`), and that crate's own -//! tests (e.g. `tests/issue51.rs`) use `named_objects_dict_handle` as an -//! XRecord owner directly. In practice this round-tripped through DWG but -//! **silently lost the XRecord through DXF** (caught by this module's own -//! save/load tests, not assumed): `CadDocument::ensure_xrecord` only writes -//! the owner's *own* `xdictionary_handle` field when `get_entity_mut(owner)` -//! succeeds (a real "entity"); a `Dictionary` owner like the root NOD isn't -//! one, so `ensure_xrecord` falls back to a runtime-only side map -//! (`xdic_by_handle`) instead. The DWG writer consults that side map -//! (`io/dwg/dwg_document_builder.rs` et al.); the DXF writer never does (no -//! `xdic_by_handle` reference anywhere under `io/dxf/writer/`) — so a -//! Dictionary-owned extension dictionary is invisible to a DXF save. This -//! is a gap in the vendored crate for this specific owner-type combination, -//! not something introduced here; per this project's own stated preference -//! to avoid needing changes to `acadrust`/`cadkernel`, the fix is to pick a -//! different, already-proven owner rather than patch it. -//! -//! **Owner used instead**: `document.header.model_space_block_handle` — a -//! real `BlockRecord` entity, the exact anchor `sketch_persist`'s own -//! `SketchScope::ModelSpace` case already proved round-trips cleanly -//! through both DWG and DXF. Model space is not a semantically perfect fit -//! for "the whole document" (a parameter table has nothing to do with model -//! space specifically), but it is guaranteed to exist in every -//! `CadDocument` and is a real entity, which is the property that actually -//! matters here. +//! Persists the drawing-wide named-parameter table in a versioned XRecord. use super::named_parameters::ParameterTable; use super::Scene; +use bincode::Options; const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; @@ -53,6 +11,7 @@ const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; /// bytes, so a table past a handful of parameters must be split across /// multiple same-code `310` entries on write and concatenated back on read. const MAX_CHUNK_BYTES: usize = u8::MAX as usize; +const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024; /// Prefixed onto the serialized blob so a future schema change can be /// detected and gracefully skipped rather than silently misreading bytes — @@ -62,7 +21,11 @@ const FORMAT_VERSION: u8 = 1; fn encode(table: &ParameterTable) -> Option> { let mut bytes = vec![FORMAT_VERSION]; - bytes.extend(bincode::serialize(table).ok()?); + let body = bincode::serialize(table).ok()?; + if body.len() >= MAX_RECORD_BYTES { + return None; + } + bytes.extend(body); Some(bytes) } @@ -71,7 +34,12 @@ fn decode(bytes: &[u8]) -> Option { if version != FORMAT_VERSION { return None; } - bincode::deserialize(body).ok() + bincode::DefaultOptions::new() + .with_fixint_encoding() + .allow_trailing_bytes() + .with_limit(MAX_RECORD_BYTES as u64) + .deserialize(body) + .ok() } impl Scene { @@ -89,7 +57,9 @@ impl Scene { if self.named_parameters.is_empty() { return; } - let Some(bytes) = encode(&self.named_parameters) else { return }; + let Some(bytes) = encode(&self.named_parameters) else { + return; + }; let owner = self.document.header.model_space_block_handle; if owner.is_null() { return; @@ -100,9 +70,10 @@ impl Scene { // not accumulate more Chunk entries every time. record.entries.clear(); for chunk in bytes.chunks(MAX_CHUNK_BYTES) { - record - .entries - .push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(chunk.to_vec()))); + record.entries.push(acadrust::objects::XRecordEntry::new( + 310, + acadrust::objects::XRecordValue::Chunk(chunk.to_vec()), + )); } } } @@ -121,12 +92,18 @@ impl Scene { if owner.is_null() { return; } - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return }; + let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { + return; + }; // Concatenate every Chunk entry in order, not just the first — see // `MAX_CHUNK_BYTES`'s doc comment. let mut bytes = Vec::new(); for entry in &record.entries { if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { + bytes.clear(); + break; + } bytes.extend_from_slice(chunk); } } @@ -147,7 +124,10 @@ mod tests { fn a_parameter_table_survives_materialize_and_reload() { let mut scene = Scene::new(); scene.named_parameters_mut().set("hole_dia", "5").unwrap(); - scene.named_parameters_mut().set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + scene + .named_parameters_mut() + .set("hole_spacing", "2 * hole_dia + 1.5") + .unwrap(); scene.materialize_named_parameters_for_save(); // Simulate the load-time side of a save/reload round trip: drop the @@ -158,7 +138,10 @@ mod tests { assert_eq!(scene.named_parameters().len(), 2); assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5)); - assert_eq!(scene.named_parameters().get("hole_dia").unwrap().source, "5"); + assert_eq!( + scene.named_parameters().get("hole_dia").unwrap().source, + "5" + ); } /// The above test only proves `materialize`/`load` agree with each @@ -169,15 +152,22 @@ mod tests { /// `sketch_persist`'s own real-bytes tests. fn full_bytes_roundtrip(ext: &str) -> ParameterTable { let mut scene = Scene::new(); - scene.named_parameters_mut().set("plate_len", "42.5").unwrap(); - scene.named_parameters_mut().set("plate_width", "plate_len / 2").unwrap(); + scene + .named_parameters_mut() + .set("plate_len", "42.5") + .unwrap(); + scene + .named_parameters_mut() + .set("plate_width", "plate_len / 2") + .unwrap(); scene.materialize_named_parameters_for_save(); let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); let mut reloaded_scene = Scene::new(); - reloaded_scene.document = crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); + reloaded_scene.document = + crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); reloaded_scene.load_named_parameters_from_document(); reloaded_scene.named_parameters @@ -202,13 +192,19 @@ mod tests { let mut scene = Scene::new(); scene.materialize_named_parameters_for_save(); let owner = scene.document.header.model_space_block_handle; - assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty table should not create an XRecord"); + assert!( + scene.document.xrecord(owner, XRECORD_KEY).is_none(), + "an empty table should not create an XRecord" + ); } #[test] fn decode_rejects_a_mismatched_format_version() { let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap()); - assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread"); + assert!( + decode(&bytes).is_none(), + "a future/unknown format version must be rejected, not misread" + ); } } diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index cdd55f428..e5c0ee788 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -2492,7 +2492,6 @@ mod texture_limit_tests { let rgba = vec![0u8; 10]; assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none()); } - #[test] fn malformed_material_images_are_rejected_even_below_the_texture_limit() { for (width, height, pixels) in [(0, 1, vec![]), (1, 0, vec![]), (1, 1, vec![0; 3]), (1, 1, vec![0; 5])] { @@ -2501,4 +2500,5 @@ mod texture_limit_tests { } assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none()); } + } diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index 2623fdcb6..fbf35c0a0 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -1,15 +1,4 @@ -//! Persistent, parametric geometric constraints — data model. -//! -//! Design: `docs/parametric_system_design.md`. This is Stage 1 of that -//! doc's §8 staged plan: pure data types plus unit tests, no document/scene -//! integration yet (that's `refresh_sketch_constraints`, wired into -//! `Scene::bump_entities` in a later stage). -//! -//! Distinct from the one-shot "Constraints" ribbon commands -//! (`crate::modules::draw::constrain`), which solve once and forget. A -//! [`SketchConstraint`] is a persisted record: it stays attached to its -//! entities and is meant to be re-solved every time referenced geometry -//! changes, not applied once. +//! Persistent sketch-constraint data types and scope management. use super::named_parameters::DrivingValue; use acadrust::types::{Handle, Vector3}; @@ -40,23 +29,32 @@ pub struct SketchRef { impl SketchRef { pub fn whole(entity: Handle) -> Self { - Self { entity, marker: None } + Self { + entity, + marker: None, + } } pub fn point(entity: Handle, marker: i32) -> Self { - Self { entity, marker: Some(marker) } + Self { + entity, + marker: Some(marker), + } } /// The circle/arc-center special case (`marker == -3`), broken out as /// its own constructor since `-3` alone reads as a magic number /// everywhere it would otherwise appear. pub fn center(entity: Handle) -> Self { - Self { entity, marker: Some(-3) } + Self { + entity, + marker: Some(-3), + } } } /// The friendly, user-facing constraint types — the "what button did they -/// click" vocabulary, one layer above the `ocs_gcs` primitives each maps +/// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps /// onto (that mapping is `constraint_map`, a later stage; see the design /// doc §2). Named and grouped the same way the existing one-shot ribbon /// tools are (`crate::modules::draw::constrain::tools`), plus the @@ -102,11 +100,8 @@ pub enum ConstraintKind { PointOnCurve, /// The distance between one point pair equals the distance between /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). - /// AutoCAD's fourth `Equal` sub-kind (`ACEQUALDISTANCECONSTRAINT`) — - /// see the design doc for why `EqualCurvature`, the other missing - /// sub-kind, isn't modeled: for the only entity types this system - /// solves (Line, Circle), it would be mathematically identical to this - /// `Equal`'s existing circle/circle (radius) branch. + /// For the supported entity types, equal curvature is already covered + /// by the circle/circle radius branch of `Equal`. EqualDistance, /// Two circles/arcs are mirror images of each other across a line — /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the @@ -118,15 +113,12 @@ pub enum ConstraintKind { /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the /// `RadiusDiameterConstrType` mode byte - /// (`dwg_native_constraints.rs`), matching how real AutoCAD represents - /// it — not a distinct native object type. + /// (`dwg_native_constraints.rs`), rather than a distinct object type. Diameter, /// The X-only (resp. Y-only) component of the distance between two /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its - /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction, again - /// matching AutoCAD's own representation rather than inventing a new - /// class. + /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction. DistanceX, DistanceY, /// A line perpendicular to a circle/arc's tangent at their point of @@ -135,9 +127,7 @@ pub enum ConstraintKind { /// passes through the circle's center" (a circle's radius is always /// normal to its own tangent), so it solves via the same `PointOnLine` /// primitive `PointOnCurve` already uses. Distinct from - /// `Perpendicular` (line-to-line only) — AutoCAD's own - /// `GeomConstraintType` enum lists `kNormal` and `kPerpendicular` - /// separately for exactly this reason. Line-Line has no meaning here + /// `Perpendicular` (line-to-line only). Line-Line has no meaning here /// (that's plain `Perpendicular`) and isn't buildable. Normal, /// An arc's arc length (`radius * sweep angle`) — `refs`: @@ -164,8 +154,7 @@ pub struct SketchConstraint { pub refs: Vec, /// The target for a dimensional constraint (a `Distance`'s length, an /// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a - /// named-parameter reference (`docs/named_parameters_design.md`, - /// resolved through `Scene::named_parameters` at solve time by + /// named-parameter reference resolved through `Scene::named_parameters` at solve time by /// `sketch_solve::build_constraint`). `None` for every purely-geometric /// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, /// Equal, Tangent). @@ -175,10 +164,7 @@ pub struct SketchConstraint { pub enabled: bool, } -/// What "one sketch" scopes to, given the app has no dedicated Sketch -/// grouping (design doc §3.1): the only existing sub-document boundary is a -/// block record, so model space and each block definition get their own -/// independent constraint set. +/// A constraint scope: model space or one block definition. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SketchScope { ModelSpace, @@ -189,12 +175,7 @@ pub enum SketchScope { } impl SketchScope { - /// The handle a [`SketchConstraintSet`] for this scope is persisted - /// under — `CadDocument::ensure_xrecord`/`xrecord`/`xrecord_mut`'s - /// `owner` argument (design doc §1.4/§5.2). Model space resolves - /// through the document rather than being a fixed handle because a - /// document's model-space block record handle is assigned when the - /// document is built, not a constant. + /// The owner handle under which this scope is persisted. pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle { match self { SketchScope::ModelSpace => document.header.model_space_block_handle, @@ -203,9 +184,8 @@ impl SketchScope { } } -/// Every persisted constraint for one [`SketchScope`]. Rebuilt-and-solved -/// wholesale on every trigger (design doc §4.2) — this struct holds only -/// the constraint records themselves, no solver state. +/// Every persisted constraint for one [`SketchScope`]. Solver state is rebuilt +/// on demand and is not stored here. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SketchConstraintSet { pub scope: SketchScope, @@ -215,30 +195,49 @@ pub struct SketchConstraintSet { /// independent solve partition in this scope — updated by /// `sketch_solve::solve_scope` each time this set is resolved. `None` /// until the first resolve (e.g. right after loading from disk, before - /// any edit has touched this scope yet). Design doc §6.3's DOF badge - /// reads this rather than recomputing it every frame. Not persisted: + /// any edit has touched this scope yet). The status badge reads this + /// rather than recomputing it every frame. Not persisted: /// it's a derived cache, not real constraint state. #[serde(skip)] pub dof: Option, - /// Cached redundant/conflicting constraints found by the last resolve - /// (design doc §6.4, stage 11) — empty whenever the scope isn't - /// over-constrained, which is the common case. Also a derived cache, not + /// Cached redundant/conflicting constraints found by the last resolve. + /// Also a derived cache, not /// persisted; a `ConflictResolverPanel` reads this rather than calling - /// `ocs_gcs::diagnosis::classify_redundant` itself. + /// `cadkernel_constraints::diagnosis::classify_redundant` itself. #[serde(skip)] - pub conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>, + pub conflicts: Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )>, } impl SketchConstraintSet { pub fn new(scope: SketchScope) -> Self { - Self { scope, constraints: Vec::new(), next_id: 0, dof: None, conflicts: Vec::new() } + Self { + scope, + constraints: Vec::new(), + next_id: 0, + dof: None, + conflicts: Vec::new(), + } } /// Appends a constraint, assigning it a fresh id unique within this set. - pub fn add(&mut self, kind: ConstraintKind, refs: Vec, driving_param: Option) -> ConstraintId { + pub fn add( + &mut self, + kind: ConstraintKind, + refs: Vec, + driving_param: Option, + ) -> ConstraintId { let id = self.next_id; self.next_id += 1; - self.constraints.push(SketchConstraint { id, kind, refs, driving_param, enabled: true }); + self.constraints.push(SketchConstraint { + id, + kind, + refs, + driving_param, + enabled: true, + }); id } @@ -253,28 +252,27 @@ impl SketchConstraintSet { self.constraints.iter().find(|c| c.id == id) } - /// Every enabled constraint referencing `entity`, regardless of which - /// of its sub-elements — the query `refresh_sketch_constraints`' - /// reverse index (design doc §4.1) is built from. + /// Every enabled constraint referencing `entity`. pub fn constraints_touching(&self, entity: Handle) -> impl Iterator { - self.constraints.iter().filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity)) + self.constraints + .iter() + .filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity)) } - /// Drops every constraint that references `entity` at all — the - /// dangling-constraint-on-delete policy (design doc §5.3, open question - /// 4): entities silently take their constraints with them when erased, - /// rather than leaving a dangling reference around. Returns the - /// removed constraints' ids, so a caller can log/report what vanished. + /// Drops every constraint that references `entity` and returns the + /// removed constraint ids. pub fn remove_all_touching(&mut self, entity: Handle) -> Vec { - let (removed, kept): (Vec<_>, Vec<_>) = - self.constraints.drain(..).partition(|c| c.refs.iter().any(|r| r.entity == entity)); + let (removed, kept): (Vec<_>, Vec<_>) = self + .constraints + .drain(..) + .partition(|c| c.refs.iter().any(|r| r.entity == entity)); self.constraints = kept; removed.into_iter().map(|c| c.id).collect() } } /// Resolves a [`SketchRef`] to its current world-space point, for building -/// an `ocs_gcs` `ParamStore` from live document geometry — the constraint +/// an `cadkernel_constraints` `ParamStore` from live document geometry — the constraint /// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted /// to the marker conventions constraint endpoints actually use (whole-entity /// `None`, an ordinary `source_points()` index, or the `-3` center case). @@ -282,11 +280,8 @@ impl SketchConstraintSet { /// marker this scheme doesn't (yet) support (e.g. `-2`, or an out-of-range /// index). /// -/// `sketch_solve`'s solver-side entity registration reads entity fields -/// directly rather than through this (it needs raw, not-yet-WCS-resolved -/// coordinates to seed `ParamId`s) — this is for UI-side consumers instead: -/// currently `glyph_anchor` (below), eventually Phase B live inference -/// (design doc §6.2) querying "what point is near the cursor". +/// Solver-side registration reads raw entity fields directly. This helper is +/// for UI-side consumers that need the current world-space position. pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option { if marker == -3 { return match entity { @@ -298,7 +293,9 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio if marker < 0 { return None; } - super::dimension_assoc::source_points(entity).get(marker as usize).copied() + super::dimension_assoc::source_points(entity) + .get(marker as usize) + .copied() } /// Below this squared distance (1e-6 world units), two points count as @@ -306,16 +303,9 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio /// purposes. const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; -/// Design doc §6.1's manual Coincident UI: given a world point (from a -/// `CadCommand::on_point` pick — trusted to already be OSNAP-snapped onto a -/// real feature, the same trust `infer_coincident_refs` places in a -/// just-drawn entity's own coordinates, for the same reason: no camera or -/// pixel radius is available once a point reaches this far from the click), -/// finds the closest addressable point (an ordinary `source_points()` index, -/// or a circle/arc's center) on any entity in `scope` other than `exclude`, -/// within [`COINCIDENT_EPSILON_SQ`]. `None` means the pick didn't land on a -/// real point — the caller should ask the user to enable an Endpoint/Center -/// object snap and try again, not silently constrain nothing. +/// Finds the addressable entity point nearest a snapped world position. +/// Returns `None` when no point in the scope is within the coincidence +/// tolerance. pub(crate) fn nearest_sketch_point( document: &acadrust::CadDocument, scope: SketchScope, @@ -338,7 +328,10 @@ pub(crate) fn nearest_sketch_point( if common.owner_handle != owner || Some(common.handle) == exclude { continue; } - for (marker, point) in super::dimension_assoc::source_points(candidate).into_iter().enumerate() { + for (marker, point) in super::dimension_assoc::source_points(candidate) + .into_iter() + .enumerate() + { consider(common.handle, marker as i32, point); } match candidate { @@ -350,23 +343,13 @@ pub(crate) fn nearest_sketch_point( best.map(|(_, r)| r) } -/// Design doc §6.2 (stage 8, Phase B live inference): for each of -/// `new_entity`'s own points, finds an existing entity in `scope` (other +/// For each point on `new_entity`, finds an existing entity in `scope` (other /// than `new_handle` itself) whose corresponding point already coincides, /// and returns a `(new, existing)` `SketchRef` pair per match — the caller /// adds each as a `Coincident` constraint. /// -/// **Deviation from this doc**: the doc's own §6.2 suggests reusing OSNAP's -/// screen-space tolerance/query; this instead checks for near-exact -/// world-space coincidence. The natural hook point is where a committed -/// entity becomes a document write (`CmdResult::CommitEntity`'s dispatch in -/// `command_driver.rs`) — well after the cursor's pixel position and camera -/// are available, with only the entity's final world coordinates left. If -/// OSNAP's endpoint mode grabbed the point while the user was drawing, the -/// new entity's coordinate already matches the existing one to floating-point -/// precision, so a tight world-space epsilon reproduces the same result -/// without re-deriving OSNAP's screen-space math or threading camera state -/// into this dispatch site. +/// Matching uses a tight world-space tolerance because this runs after the +/// snapped entity has been committed. pub(crate) fn infer_coincident_refs( document: &acadrust::CadDocument, scope: SketchScope, @@ -385,12 +368,18 @@ pub(crate) fn infer_coincident_refs( if common.handle == new_handle || common.owner_handle != owner { continue; } - for (marker, point) in super::dimension_assoc::source_points(candidate).iter().enumerate() { + for (marker, point) in super::dimension_assoc::source_points(candidate) + .iter() + .enumerate() + { let dx = point.x - new_point.x; let dy = point.y - new_point.y; let dz = point.z - new_point.z; if dx * dx + dy * dy + dz * dz <= COINCIDENT_EPSILON_SQ { - pairs.push((SketchRef::point(new_handle, new_marker as i32), SketchRef::point(common.handle, marker as i32))); + pairs.push(( + SketchRef::point(new_handle, new_marker as i32), + SketchRef::point(common.handle, marker as i32), + )); break; } } @@ -437,8 +426,12 @@ impl ConstraintKind { /// value for a dimensional kind (Distance/Angle/Radius). pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { match (constraint.kind, &constraint.driving_param) { - (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()), - (_, Some(DrivingValue::Literal(value))) => format!("{} {value:.2}", constraint.kind.glyph_symbol()), + (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => { + format!("{} {value:.1}°", constraint.kind.glyph_symbol()) + } + (_, Some(DrivingValue::Literal(value))) => { + format!("{} {value:.2}", constraint.kind.glyph_symbol()) + } // A named reference has no single resolved number to show without // threading `ParameterTable` into every glyph-render call site // (`src/ui/overlay.rs`) — showing the name itself is enough for now; @@ -446,7 +439,9 @@ pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { // once a named `driving_param` can actually be authored through the // UI (nothing can yet — this arm exists so the match is exhaustive // and correct ahead of that UI, not because it's reachable today). - (_, Some(DrivingValue::Named(name))) => format!("{} {name}", constraint.kind.glyph_symbol()), + (_, Some(DrivingValue::Named(name))) => { + format!("{} {name}", constraint.kind.glyph_symbol()) + } (_, None) => constraint.kind.glyph_symbol().to_string(), } } @@ -457,16 +452,21 @@ pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { /// back to a representative point on the entity (a line's midpoint, a /// circle's rightmost point) since there's no single named point to anchor /// on otherwise. -pub(crate) fn glyph_anchor(document: &acadrust::CadDocument, constraint: &SketchConstraint) -> Option { +pub(crate) fn glyph_anchor( + document: &acadrust::CadDocument, + constraint: &SketchConstraint, +) -> Option { let r = constraint.refs.first()?; let entity = document.get_entity(r.entity)?; if let Some(marker) = r.marker { return resolve_point(entity, marker); } match entity { - acadrust::EntityType::Line(l) => { - Some(Vector3::new((l.start.x + l.end.x) * 0.5, (l.start.y + l.end.y) * 0.5, (l.start.z + l.end.z) * 0.5)) - } + acadrust::EntityType::Line(l) => Some(Vector3::new( + (l.start.x + l.end.x) * 0.5, + (l.start.y + l.end.y) * 0.5, + (l.start.z + l.end.z) * 0.5, + )), acadrust::EntityType::Circle(c) => Some(c.point_at_angle_wcs(0.0)), _ => None, } @@ -484,16 +484,20 @@ impl super::Scene { /// scope — this is the one way to reach a scope's set for both reading /// and mutating. pub fn sketch_constraint_set_mut(&mut self, scope: SketchScope) -> &mut SketchConstraintSet { - if let Some(index) = self.sketch_constraints.iter().position(|s| s.scope == scope) { + if let Some(index) = self + .sketch_constraints + .iter() + .position(|s| s.scope == scope) + { &mut self.sketch_constraints[index] } else { - self.sketch_constraints.push(SketchConstraintSet::new(scope)); + self.sketch_constraints + .push(SketchConstraintSet::new(scope)); self.sketch_constraints.last_mut().expect("just pushed") } } - /// Design doc §5.3/§12 (open question 3, now resolved): copy/paste's - /// handle remapping lives locally in each command that duplicates + /// Handle remapping lives in each command that duplicates /// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR, /// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own /// (clipboard paste, `src/app/command_driver.rs`) — rather than in one @@ -507,18 +511,28 @@ impl super::Scene { /// scope, then triggers a solve for the newly duplicated geometry the /// same way any other edit would. A no-op when `handle_map` is empty or /// nothing constrained was duplicated. - pub fn duplicate_sketch_constraints_for(&mut self, handle_map: &rustc_hash::FxHashMap) { + pub fn duplicate_sketch_constraints_for( + &mut self, + handle_map: &rustc_hash::FxHashMap, + ) { if handle_map.is_empty() { return; } - let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = Vec::new(); + let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = + Vec::new(); for (scope_index, set) in self.sketch_constraints.iter().enumerate() { for c in &set.constraints { if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { continue; } - let new_refs: Vec = - c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect(); + let new_refs: Vec = c + .refs + .iter() + .map(|r| SketchRef { + entity: handle_map[&r.entity], + marker: r.marker, + }) + .collect(); to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); } } @@ -532,7 +546,10 @@ impl super::Scene { } touched.sort(); touched.dedup(); - let changes: Vec<(Handle, super::ChangeKind)> = touched.into_iter().map(|h| (h, super::ChangeKind::Modified)).collect(); + let changes: Vec<(Handle, super::ChangeKind)> = touched + .into_iter() + .map(|h| (h, super::ChangeKind::Modified)) + .collect(); self.bump_entities(&changes); } @@ -547,14 +564,21 @@ impl super::Scene { let mut out = Vec::new(); for set in &self.sketch_constraints { for c in &set.constraints { - let Some(DrivingValue::Named(n)) = &c.driving_param else { continue }; + let Some(DrivingValue::Named(n)) = &c.driving_param else { + continue; + }; if n != name { continue; } let mut entities: Vec = c.refs.iter().map(|r| r.entity).collect(); entities.sort(); entities.dedup(); - out.push(ParameterUsage { scope: set.scope, constraint_id: c.id, kind: c.kind, entities }); + out.push(ParameterUsage { + scope: set.scope, + constraint_id: c.id, + kind: c.kind, + entities, + }); } } out @@ -582,20 +606,38 @@ mod tests { #[test] fn add_assigns_increasing_ids_and_get_finds_them() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); - let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(DrivingValue::Literal(25.0))); + let a = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(1))], + None, + ); + let b = set.add( + ConstraintKind::Distance, + vec![SketchRef::whole(h(1))], + Some(DrivingValue::Literal(25.0)), + ); assert_ne!(a, b); assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); - assert_eq!(set.get(b).unwrap().driving_param, Some(DrivingValue::Literal(25.0))); + assert_eq!( + set.get(b).unwrap().driving_param, + Some(DrivingValue::Literal(25.0)) + ); } #[test] fn remove_drops_only_the_matching_id() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); + let a = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(1))], + None, + ); let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None); assert!(set.remove(a)); - assert!(!set.remove(a), "removing twice should report nothing removed the second time"); + assert!( + !set.remove(a), + "removing twice should report nothing removed the second time" + ); assert!(set.get(a).is_none()); assert!(set.get(b).is_some()); } @@ -603,8 +645,16 @@ mod tests { #[test] fn constraints_touching_finds_entity_regardless_of_marker() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None); - set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + None, + ); + set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(3))], + None, + ); let touching_1: Vec<_> = set.constraints_touching(h(1)).collect(); assert_eq!(touching_1.len(), 1); @@ -616,21 +666,40 @@ mod tests { #[test] fn constraints_touching_skips_disabled() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let id = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); - set.constraints.iter_mut().find(|c| c.id == id).unwrap().enabled = false; + let id = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(1))], + None, + ); + set.constraints + .iter_mut() + .find(|c| c.id == id) + .unwrap() + .enabled = false; assert_eq!(set.constraints_touching(h(1)).count(), 0); } #[test] fn remove_all_touching_drops_every_constraint_referencing_the_entity() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let coincident = set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None); - let horizontal_other = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None); + let coincident = set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + None, + ); + let horizontal_other = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(3))], + None, + ); let removed = set.remove_all_touching(h(1)); assert_eq!(removed, vec![coincident]); assert!(set.get(coincident).is_none()); - assert!(set.get(horizontal_other).is_some(), "unrelated entity's constraint must survive"); + assert!( + set.get(horizontal_other).is_some(), + "unrelated entity's constraint must survive" + ); } #[test] @@ -644,54 +713,87 @@ mod tests { #[test] fn ref_center_constructor_matches_the_dash_three_convention() { let r = SketchRef::center(h(7)); - assert_eq!(r, SketchRef { entity: h(7), marker: Some(-3) }); + assert_eq!( + r, + SketchRef { + entity: h(7), + marker: Some(-3) + } + ); } #[test] fn sketch_constraint_set_round_trips_through_bincode() { let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); - set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None); - set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(DrivingValue::Literal(12.5))); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + None, + ); + set.add( + ConstraintKind::Distance, + vec![SketchRef::whole(h(3))], + Some(DrivingValue::Literal(12.5)), + ); let bytes = bincode::serialize(&set).expect("serialize"); let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize"); assert_eq!(restored.scope, set.scope); assert_eq!(restored.constraints.len(), set.constraints.len()); - assert_eq!(restored.constraints[1].driving_param, Some(DrivingValue::Literal(12.5))); + assert_eq!( + restored.constraints[1].driving_param, + Some(DrivingValue::Literal(12.5)) + ); assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); } #[test] fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { let mut scene = super::super::Scene::new(); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], - Some(DrivingValue::Named("gap".to_string())), - ); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Radius, - vec![SketchRef::whole(h(2))], - Some(DrivingValue::Named("gap".to_string())), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("gap".to_string())), + ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(2))], + Some(DrivingValue::Named("gap".to_string())), + ); // Unrelated: a literal-driven constraint and one driven by a // different name must not show up. - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Radius, - vec![SketchRef::whole(h(3))], - Some(DrivingValue::Literal(5.0)), - ); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], - Some(DrivingValue::Named("other".to_string())), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(3))], + Some(DrivingValue::Literal(5.0)), + ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], + Some(DrivingValue::Named("other".to_string())), + ); let usage = scene.parameter_usage("gap"); - assert_eq!(usage.len(), 2, "exactly the two constraints driven by 'gap', got {usage:?}"); - assert!(usage.iter().any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); - assert!(usage.iter().any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); + assert_eq!( + usage.len(), + 2, + "exactly the two constraints driven by 'gap', got {usage:?}" + ); + assert!(usage + .iter() + .any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); + assert!(usage + .iter() + .any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); assert_eq!(scene.parameter_usage("nonexistent").len(), 0); } @@ -700,11 +802,13 @@ mod tests { fn parameter_usage_searches_every_scope_not_just_model_space() { let mut scene = super::super::Scene::new(); let block = h(99); - scene.sketch_constraint_set_mut(SketchScope::Block(block)).add( - ConstraintKind::Radius, - vec![SketchRef::whole(h(1))], - Some(DrivingValue::Named("r".to_string())), - ); + scene + .sketch_constraint_set_mut(SketchScope::Block(block)) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(1))], + Some(DrivingValue::Named("r".to_string())), + ); let usage = scene.parameter_usage("r"); assert_eq!(usage.len(), 1); assert_eq!(usage[0].scope, SketchScope::Block(block)); @@ -716,11 +820,13 @@ mod tests { // A Distance constraint whose two points are both on the same // entity (e.g. a line's own start and end) must list that entity // once, not twice. - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], - Some(DrivingValue::Named("len".to_string())), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("len".to_string())), + ); let usage = scene.parameter_usage("len"); assert_eq!(usage.len(), 1); assert_eq!(usage[0].entities, vec![h(1)]); diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs index a51088cdf..0d76c66b1 100644 --- a/src/scene/sketch_persist.rs +++ b/src/scene/sketch_persist.rs @@ -1,23 +1,9 @@ -//! Save/load integration for [`super::sketch_constraints::SketchConstraintSet`] -//! — design doc §5.2/§8 stage 4, the "lazy" persistence model that section -//! recommends: constraint sets live only in `Scene::sketch_constraints` -//! in-memory (§8 stage 3) and are materialized into `document.objects` as -//! XRecords only around a save, and read back out right after a load. The -//! XRecord round-trip mechanism itself (`CadDocument::ensure_xrecord`/ -//! `xrecord`/`xrecord_mut`) was proven in stage 2 -//! (`tests/sketch_constraints_xrecord_roundtrip.rs`) — this module is just -//! the app-level wiring: which owner handles to check, and when. -//! -//! One named XRecord (keyed [`XRECORD_KEY`]) hangs off each scope's own -//! owner handle's extension dictionary — model space's `BlockRecord` handle, -//! or a `SketchScope::Block`'s handle — rather than a single shared -//! dictionary, since [`super::sketch_constraints::SketchScope::owner_handle`] -//! already gives that handle directly and `ensure_xrecord`/`xrecord` are -//! themselves per-owner. +//! Persists each sketch constraint set in a versioned, chunked XRecord. use super::sketch_constraints::SketchConstraintSet; use super::Scene; use acadrust::{CadDocument, Handle}; +use bincode::Options; const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS"; @@ -35,6 +21,7 @@ const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS"; /// around it without touching the vendored crate — `CadDocument::xrecord`'s /// `entries: Vec` already supports repeating a code. const MAX_CHUNK_BYTES: usize = u8::MAX as usize; +const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024; /// Removes a named XRecord from `owner`'s extension dictionary, if present. /// The dictionary itself is left in place even if now empty — other code may @@ -50,32 +37,28 @@ fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) { else { return; }; - let Some(index) = dictionary.entries.iter().position(|(name, _)| name.eq_ignore_ascii_case(key)) else { + let Some(index) = dictionary + .entries + .iter() + .position(|(name, _)| name.eq_ignore_ascii_case(key)) + else { return; }; let (_, record_handle) = dictionary.entries.remove(index); document.objects.remove(&record_handle); } -/// Prefixed onto every serialized blob so a future schema change can be -/// detected and gracefully skipped (dropping just that one scope's -/// constraints, not the whole load) rather than silently misreading bytes -/// or panicking — design doc §5.2's explicit call-out, and §7's flagged -/// "no migration path designed yet": bumping this is the signal a real -/// migration is needed, not a substitute for one. -/// -/// Bumped 1 -> 2 by `named_parameters_design.md` stage 3: -/// `SketchConstraint::driving_param` changed from a bare `Option` to -/// `Option` (an enum, with its own -/// discriminant), so a version-1 blob's bytes no longer line up with the -/// current struct shape — exactly the "detected and gracefully skipped" -/// case this version byte exists for, not something to migrate (no -/// migration path exists yet, per the call-out above). +/// Serialized schema version. Unknown versions are skipped without affecting +/// the rest of the drawing. const FORMAT_VERSION: u8 = 2; fn encode(set: &SketchConstraintSet) -> Option> { let mut bytes = vec![FORMAT_VERSION]; - bytes.extend(bincode::serialize(set).ok()?); + let body = bincode::serialize(set).ok()?; + if body.len() >= MAX_RECORD_BYTES { + return None; + } + bytes.extend(body); Some(bytes) } @@ -84,7 +67,12 @@ fn decode(bytes: &[u8]) -> Option { if version != FORMAT_VERSION { return None; } - bincode::deserialize(body).ok() + bincode::DefaultOptions::new() + .with_fixint_encoding() + .allow_trailing_bytes() + .with_limit(MAX_RECORD_BYTES as u64) + .deserialize(body) + .ok() } impl Scene { @@ -145,16 +133,26 @@ impl Scene { /// blank document has nothing to find anyway). pub(crate) fn load_sketch_constraints_from_document(&mut self) { self.sketch_constraints.clear(); - let owners: Vec = - self.document.block_records.iter().map(|record| record.handle).collect(); + let owners: Vec = self + .document + .block_records + .iter() + .map(|record| record.handle) + .collect(); for owner in owners { - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { continue }; + let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { + continue; + }; // Concatenate every Chunk entry in order, not just the first — // a set materialized past `MAX_CHUNK_BYTES` spans more than one // same-code `310` entry (see that constant's doc comment). let mut bytes = Vec::new(); for entry in &record.entries { if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { + bytes.clear(); + break; + } bytes.extend_from_slice(chunk); } } @@ -182,11 +180,9 @@ mod tests { Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), ))); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(a)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); scene.materialize_sketch_constraints_for_save(); // Simulate the load-time side of a save/reload round trip: drop the @@ -195,7 +191,9 @@ mod tests { scene.sketch_constraints.clear(); scene.load_sketch_constraints_from_document(); - let set = scene.sketch_constraint_set(SketchScope::ModelSpace).expect("constraint set should have been reloaded"); + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("constraint set should have been reloaded"); assert_eq!(set.constraints.len(), 1); assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]); @@ -218,16 +216,20 @@ mod tests { Vector3::new(10.0, 5.0, 0.0), Vector3::new(20.0, 5.0, 0.0), ))); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], - None, - ); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::whole(b)], - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + None, + ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::whole(b)], + Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), + ); scene.materialize_sketch_constraints_for_save(); let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -237,9 +239,12 @@ mod tests { .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); reloaded_scene.load_sketch_constraints_from_document(); - reloaded_scene.sketch_constraint_set(SketchScope::ModelSpace).cloned().unwrap_or_else(|| { - panic!("no ModelSpace constraint set survived the {ext} save/load round trip") - }) + reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .cloned() + .unwrap_or_else(|| { + panic!("no ModelSpace constraint set survived the {ext} save/load round trip") + }) } #[test] @@ -248,7 +253,10 @@ mod tests { assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))); + assert_eq!( + restored.constraints[1].driving_param, + Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)) + ); } #[test] @@ -257,7 +265,10 @@ mod tests { assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))); + assert_eq!( + restored.constraints[1].driving_param, + Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)) + ); } #[test] @@ -274,10 +285,22 @@ mod tests { // handle"), so look it up from the table by name instead of using // the return value directly. let _insert_handle = scene - .create_block_from_entities(&[a], "OCS_STAGE4_TEST_BLOCK", &acadrust::types::Transform::identity(), &acadrust::types::Transform::identity()) + .create_block_from_entities( + &[a], + "OCS_STAGE4_TEST_BLOCK", + &acadrust::types::Transform::identity(), + &acadrust::types::Transform::identity(), + ) .expect("create a block definition to own the constraint set"); - let br_handle = scene.document.block_records.get("OCS_STAGE4_TEST_BLOCK").expect("block record should exist").handle; - scene.sketch_constraint_set_mut(SketchScope::Block(br_handle)).add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); + let br_handle = scene + .document + .block_records + .get("OCS_STAGE4_TEST_BLOCK") + .expect("block record should exist") + .handle; + scene + .sketch_constraint_set_mut(SketchScope::Block(br_handle)) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); scene.materialize_sketch_constraints_for_save(); scene.sketch_constraints.clear(); @@ -300,7 +323,10 @@ mod tests { scene.materialize_sketch_constraints_for_save(); let owner = scene.document.header.model_space_block_handle; - assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty constraint set should not create an XRecord"); + assert!( + scene.document.xrecord(owner, XRECORD_KEY).is_none(), + "an empty constraint set should not create an XRecord" + ); } #[test] @@ -316,29 +342,50 @@ mod tests { Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), ))); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); scene.materialize_sketch_constraints_for_save(); let owner = scene.document.header.model_space_block_handle; - assert!(scene.document.xrecord(owner, XRECORD_KEY).is_some(), "sanity: the first save should have materialized an XRecord"); + assert!( + scene.document.xrecord(owner, XRECORD_KEY).is_some(), + "sanity: the first save should have materialized an XRecord" + ); // Clear every constraint (mirrors deleting the constrained entity, or // removing the last constraint by hand) and save again. - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).constraints.clear(); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .constraints + .clear(); scene.materialize_sketch_constraints_for_save(); - assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "the stale XRecord from the earlier non-empty save must be removed, not left behind"); + assert!( + scene.document.xrecord(owner, XRECORD_KEY).is_none(), + "the stale XRecord from the earlier non-empty save must be removed, not left behind" + ); // And the load path must agree: nothing resurrects on reload. scene.sketch_constraints.clear(); scene.load_sketch_constraints_from_document(); - assert!(scene.sketch_constraint_set(SketchScope::ModelSpace).is_none(), "no constraints should come back after the set was cleared and re-saved"); + assert!( + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .is_none(), + "no constraints should come back after the set was cleared and re-saved" + ); } #[test] fn decode_rejects_a_mismatched_format_version() { let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; - bytes.extend(bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap()); - assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread"); + bytes.extend( + bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap(), + ); + assert!( + decode(&bytes).is_none(), + "a future/unknown format version must be rejected, not misread" + ); } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index d2e36a030..75e867cbb 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -1,81 +1,5 @@ -//! Wires [`sketch_constraints::SketchConstraintSet`] into `Scene::bump_entities` -//! — design doc §4.1/§4.2, stage 3 of its §8 staged plan. -//! -//! `refresh_sketch_constraints` is one more link in `bump_entities`'s -//! existing in-line derived-geometry chain, alongside -//! `refresh_associative_dimensions`/`_hatches`/`_centerlines`: given the -//! handles that just changed, find which constraint scopes are affected, -//! rebuild each affected scope's `ocs_gcs::System` from scratch (full -//! rebuild-and-solve, no incremental state — see the design doc §4.2 for -//! why), solve, and write any moved geometry back through the same -//! undo-recording path those other passes use. -//! -//! Maps every [`ConstraintKind`]. `Tangent` covers Line-Circle -//! (`C2LDistance` with a driven zero-distance target aliased to the -//! circle's own radius via `internal: false`) and Circle-Circle -//! (`TangentCircumf`) — Line-Line has no meaning, so that ref shape builds -//! nothing, same "skip, don't panic" contract every other unbuildable -//! constraint already gets. `ccw`/`internal` are picked from the pair's -//! *current* geometry (which side of the line the circle already sits on; -//! whether the circles are already nested) so the very first solve doesn't -//! have to cross a sign-flip singularity to reach the nearest valid -//! tangent configuration. -//! -//! An `Arc` registers as a full [`EntityGeom::Arc`]: center, radius, -//! `start_angle`/`end_angle`, *and* real `start`/`end` points (marker `0`/ -//! `1`, matching `Line`'s own convention — see `sketch_constraints:: -//! resolve_point`'s read-side counterpart). Naively, `start`/`end` would be -//! independent free params that drift away from "actually on the circle at -//! that angle" the moment anything pulls on them — planegcs itself avoids -//! that by adding "arc rules" constraints internally whenever an arc is -//! created; `ocs_gcs` has no dedicated helper for that, but it turns out not -//! to need one: `ocs_gcs::constraints::curve_generic::CurveValue` already -//! ties a point's `x`/`y` to *any* `Curve`'s value at a parameter, and -//! `geo::Arc` already implements `Curve` (its `value(u)` is exactly -//! "point on the circle at angle `u`"). So `solve_scope` adds four -//! `CurveValue` constraints per registered arc — `start.x`/`start.y` tied -//! to the curve at `start_angle`, `end.x`/`end.y` tied to it at -//! `end_angle` — right after building every `SketchConstraint`'s own -//! system-level constraints, unconditionally, the same way planegcs's own -//! arc rules apply regardless of which constraints actually reference the -//! arc. That's what makes Coincident/PointOnCurve/Midpoint/EqualDistance/ -//! Symmetric on an arc's actual endpoint (as opposed to its whole curve or -//! its center) work: they resolve through the ordinary `point_ref`/ -//! `point_for_marker` machinery once markers `0`/`1` return real, -//! consistency-anchored points, no per-kind special-casing needed. -//! -//! Every whole-circle-shaped kind (Concentric, Tangent, Equal, CenterPoint, -//! Radius, Diameter, Fixed, Normal) still works on an arc via its -//! `.circle` field — `whole_circle` extracts it generically; `Tangent`/ -//! `Normal`'s own inline matches (which don't go through `whole_circle`) -//! have explicit `EntityGeom::Arc` arms alongside their `Circle` ones for -//! the same reason. -//! -//! An `Ellipse` registers as `ocs_gcs::geo::Ellipse { center, focus1, -//! radmin }` (`register_entity`'s `Ellipse` arm converts acadrust's -//! center/major-axis-vector/ratio parametrization to this one). Unlike -//! Arc's `start`/`end`, `focus1` isn't an extra derived point on top of the -//! "real" shape params — it *is* one, jointly with `radmin`, encoding -//! orientation and eccentricity. But it's stored as an absolute point, not -//! an offset from `center`, so a constraint that pulls only on `center` -//! (Concentric/CenterPoint being the natural case) leaves `focus1` -//! genuinely untouched — correct for the solver, wrong for the geometry: -//! `center` moved, `focus1` didn't, so the vector `focus1 - center` (what -//! write-back actually derives `major_axis`/`minor_axis_ratio` from) has -//! changed, and the ellipse appears to rotate and reshape even though -//! nothing asked it to. `solve_scope` adds two ellipse-rules -//! `ocs_gcs::constraints::point_line::Difference` constraints per -//! registered ellipse — `focus1.x - center.x` and `focus1.y - center.y` -//! pinned to their pre-solve values — so `focus1` translates rigidly with -//! `center` whenever nothing else constrains it, the same "stays put -//! unless something actually pulls on it" behavior Arc's own spare DOF -//! already has. `radmin` needs no equivalent: as a lone scalar it isn't -//! coupled to `center`'s movement, so it already stays at its seed value -//! once unreferenced by anything. Because these rules already pin `focus1` -//! once `center` is pinned, `Fixed`'s `Ellipse` arm only pins `center`/ -//! `radmin` directly, not `focus1` — pinning it too would be redundant -//! with the rules, same as `Fixed`'s `Arc` arm not re-pinning `start`/ -//! `end`. +//! Builds and solves persistent two-dimensional sketch constraints through the geometry kernel. +//! Unsupported entity shapes, invalid target values, and incompatible planes are skipped. use std::collections::HashMap; use std::rc::Rc; @@ -83,23 +7,29 @@ use std::rc::Rc; use acadrust::entities::EntityType; use acadrust::types::Handle; -use ocs_gcs::constraints::angle_distance::L2LAngle; -use ocs_gcs::constraints::circle_arc::{ArcLength, C2LDistance, P2CDistance, TangentCircumf}; -use ocs_gcs::constraints::curve_generic::CurveValue; -use ocs_gcs::constraints::point_line::{ - CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, Parallel as ParallelConstraint, - Perpendicular as PerpendicularConstraint, PointOnLine, P2PDistance, +use cadkernel_constraints::constraints::angle_distance::L2LAngle; +use cadkernel_constraints::constraints::circle_arc::{ + ArcLength, C2LDistance, P2CDistance, TangentCircumf, }; -use ocs_gcs::constraints::Constraint; -use ocs_gcs::geo::{Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint}; -use ocs_gcs::solvers::dogleg::solve_dl; -use ocs_gcs::system::System; +use cadkernel_constraints::constraints::curve_generic::CurveValue; +use cadkernel_constraints::constraints::point_line::{ + CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, + Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, +}; +use cadkernel_constraints::constraints::Constraint; +use cadkernel_constraints::geo::{ + Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint, +}; +use cadkernel_constraints::solvers::dogleg::solve_dl; +use cadkernel_constraints::system::System; use super::named_parameters::ParameterTable; -use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; +use super::sketch_constraints::{ + ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, +}; use super::{ChangeKind, Scene}; -/// One referenced entity's geometry, registered into an `ocs_gcs::System`'s +/// One referenced entity's geometry, registered into an `cadkernel_constraints::System`'s /// parameter store. #[derive(Clone, Copy)] enum EntityGeom { @@ -111,7 +41,7 @@ enum EntityGeom { /// for every registered arc. See this module's doc comment. Arc(GArc), /// Center-and-shape support only — see `register_entity`'s `Ellipse` - /// arm for the acadrust-to-`ocs_gcs` parametrization conversion, the + /// arm for the acadrust-to-`cadkernel_constraints` parametrization conversion, the /// ellipse-rules `Difference` constraints `solve_scope` adds for every /// registered ellipse to keep `focus1` translating rigidly with /// `center` (this module's doc comment), and that same doc comment for @@ -121,7 +51,7 @@ enum EntityGeom { } impl EntityGeom { - /// The `ocs_gcs::geo::Point` for a marker on this entity — `None` for + /// The `cadkernel_constraints::geo::Point` for a marker on this entity — `None` for /// a marker this entity type/value doesn't support (see /// `sketch_constraints::resolve_point` for the same convention on the /// read side). @@ -160,7 +90,7 @@ fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { /// Reads `handle`'s live geometry and registers it as fresh, free (never /// `driven`) parameters in `sys` — every referenced entity is solved for, -/// nothing is a priori fixed; per planegcs-style solvers, an under- +/// nothing is a priori fixed; per the 2D kernel, an under- /// constrained scope simply converges to the nearest configuration to its /// current one, which is the correct behavior for a persistent system where /// "what stays put" is a property of how many constraints exist, not a @@ -168,15 +98,25 @@ fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { /// `constrain::apply_*` commands, which do fix the first-picked entity — /// they solve exactly one constraint in isolation, so they need that /// convention; a whole scope's constraint graph does not). -fn register_entity(document: &acadrust::CadDocument, sys: &mut System, handle: Handle) -> Option { +fn register_entity( + document: &acadrust::CadDocument, + sys: &mut System, + handle: Handle, +) -> Option { match document.get_entity(handle)? { EntityType::Line(l) => { - let p1 = GPoint::new(sys.add_param(l.start.x, false), sys.add_param(l.start.y, false)); + let p1 = GPoint::new( + sys.add_param(l.start.x, false), + sys.add_param(l.start.y, false), + ); let p2 = GPoint::new(sys.add_param(l.end.x, false), sys.add_param(l.end.y, false)); Some(EntityGeom::Line(GLine { p1, p2 })) } EntityType::Circle(c) => { - let center = GPoint::new(sys.add_param(c.center.x, false), sys.add_param(c.center.y, false)); + let center = GPoint::new( + sys.add_param(c.center.x, false), + sys.add_param(c.center.y, false), + ); let rad = sys.add_param(c.radius, false); Some(EntityGeom::Circle(GCircle { center, rad })) } @@ -187,36 +127,66 @@ fn register_entity(document: &acadrust::CadDocument, sys: &mut System, handle: H // registration time) — `solve_scope` adds the arc-rules // constraints that keep them that way under solving. See this // module's doc comment. - let center = GPoint::new(sys.add_param(a.center.x, false), sys.add_param(a.center.y, false)); + let center = GPoint::new( + sys.add_param(a.center.x, false), + sys.add_param(a.center.y, false), + ); let rad = sys.add_param(a.radius, false); let start_angle = sys.add_param(a.start_angle, false); let end_angle = sys.add_param(a.end_angle, false); let start_seed = a.start_point(); let end_seed = a.end_point(); - let start = GPoint::new(sys.add_param(start_seed.x, false), sys.add_param(start_seed.y, false)); - let end = GPoint::new(sys.add_param(end_seed.x, false), sys.add_param(end_seed.y, false)); - Some(EntityGeom::Arc(GArc { circle: GCircle { center, rad }, start, end, start_angle, end_angle })) + let start = GPoint::new( + sys.add_param(start_seed.x, false), + sys.add_param(start_seed.y, false), + ); + let end = GPoint::new( + sys.add_param(end_seed.x, false), + sys.add_param(end_seed.y, false), + ); + Some(EntityGeom::Arc(GArc { + circle: GCircle { center, rad }, + start, + end, + start_angle, + end_angle, + })) } EntityType::Ellipse(el) => { // acadrust's `Ellipse` is center + major-axis vector (its length - // is the major radius) + minor/major ratio; `ocs_gcs::geo:: + // is the major radius) + minor/major ratio; `cadkernel_constraints::geo:: // Ellipse` is center + one focus + minor radius. Converting: // minor_radius = major_radius * ratio, then the focus distance // c follows from a² = b² + c² (standard ellipse identity), and // focus1 sits `c` along the major-axis direction from center. let major_radius = el.major_axis.length(); let minor_radius = major_radius * el.minor_axis_ratio; - let focus_dist = (major_radius * major_radius - minor_radius * minor_radius).max(0.0).sqrt(); + let focus_dist = (major_radius * major_radius - minor_radius * minor_radius) + .max(0.0) + .sqrt(); // Direction is meaningless once major_radius is ~0 (a // degenerate point-ellipse) — arbitrarily fall back to +X // rather than dividing by ~0 in `normalize`. - let unit_major = - if major_radius > 1e-9 { el.major_axis.normalize() } else { acadrust::types::Vector3::UNIT_X }; + let unit_major = if major_radius > 1e-9 { + el.major_axis.normalize() + } else { + acadrust::types::Vector3::UNIT_X + }; let focus1_point = el.center + unit_major * focus_dist; - let center = GPoint::new(sys.add_param(el.center.x, false), sys.add_param(el.center.y, false)); - let focus1 = GPoint::new(sys.add_param(focus1_point.x, false), sys.add_param(focus1_point.y, false)); + let center = GPoint::new( + sys.add_param(el.center.x, false), + sys.add_param(el.center.y, false), + ); + let focus1 = GPoint::new( + sys.add_param(focus1_point.x, false), + sys.add_param(focus1_point.y, false), + ); let radmin = sys.add_param(minor_radius, false); - Some(EntityGeom::Ellipse(GEllipse { center, focus1, radmin })) + Some(EntityGeom::Ellipse(GEllipse { + center, + focus1, + radmin, + })) } _ => None, } @@ -232,23 +202,82 @@ fn resolve_ref( cache: &mut HashMap, r: SketchRef, ) -> Option { - if !cache.contains_key(&r.entity) { - let geom = register_entity(document, sys, r.entity)?; - cache.insert(r.entity, geom); + if let Some(&geometry) = cache.get(&r.entity) { + return Some(geometry); } - Some(*cache.get(&r.entity)?) + let geometry = register_entity(document, sys, r.entity)?; + cache.insert(r.entity, geometry); + Some(geometry) } -/// One [`SketchConstraint`]'s `ocs_gcs` construction: every system-level +const PLANE_EPS: f64 = 1e-9; + +fn is_world_z(normal: acadrust::types::Vector3) -> bool { + normal.x.abs() <= PLANE_EPS + && normal.y.abs() <= PLANE_EPS + && (normal.z - 1.0).abs() <= PLANE_EPS +} + +/// Returns the world-XY plane elevation supported by the current 2D bridge. +fn entity_plane_z(entity: &EntityType) -> Option { + let z = match entity { + EntityType::Line(line) + if is_world_z(line.normal) && (line.start.z - line.end.z).abs() <= PLANE_EPS => + { + line.start.z + } + EntityType::Circle(circle) if is_world_z(circle.normal) => circle.center.z, + EntityType::Arc(arc) if is_world_z(arc.normal) => arc.center.z, + EntityType::Ellipse(ellipse) + if is_world_z(ellipse.normal) && ellipse.major_axis.z.abs() <= PLANE_EPS => + { + ellipse.center.z + } + _ => return None, + }; + z.is_finite().then_some(z) +} + +fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRef]) -> bool { + let mut plane_z = None; + for reference in refs { + let Some(entity) = document.get_entity(reference.entity) else { + return false; + }; + let Some(z) = entity_plane_z(entity) else { + return false; + }; + if plane_z.is_some_and(|expected: f64| (expected - z).abs() > PLANE_EPS) { + return false; + } + plane_z = Some(z); + } + plane_z.is_some() +} + +fn resolved_target(params: &ParameterTable, constraint: &SketchConstraint) -> Option { + let value = constraint.driving_param.as_ref()?.resolve(params).ok()?; + if !value.is_finite() { + return None; + } + let must_be_positive = matches!( + constraint.kind, + ConstraintKind::Distance + | ConstraintKind::Radius + | ConstraintKind::Diameter + | ConstraintKind::ArcLength + ); + (!must_be_positive || value > 0.0).then_some(value) +} + +/// One [`SketchConstraint`]'s `cadkernel_constraints` construction: every system-level /// constraint it contributes (most kinds contribute exactly one; a few — /// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two /// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, /// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be /// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape /// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, -/// for a dimensional kind, a `driving_param` that fails to resolve: an -/// undefined named-parameter reference or a division by zero in its -/// formula, design doc `named_parameters_design.md` stage 3). A constraint +/// for a dimensional kind, a `driving_param` that fails to resolve). A constraint /// that can't be built is simply skipped for this solve, not an error: /// geometry it would have constrained is left alone, matching how a /// dangling associative-dimension reference degrades today rather than @@ -263,19 +292,24 @@ fn build_constraint( params: &ParameterTable, c: &SketchConstraint, ) -> Vec> { - if !c.enabled { + if !c.enabled || !refs_share_supported_plane(document, &c.refs) { return Vec::new(); } - let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? { + let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( + document, sys, cache, r, + )? { EntityGeom::Line(l) => Some(l), EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => None, }; - let whole_circle = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? { - EntityGeom::Circle(circ) => Some(circ), - EntityGeom::Arc(a) => Some(a.circle), - EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, - }; + let whole_circle = + |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( + document, sys, cache, r, + )? { + EntityGeom::Circle(circ) => Some(circ), + EntityGeom::Arc(a) => Some(a.circle), + EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, + }; let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { let marker = r.marker?; resolve_ref(document, sys, cache, r)?.point_for_marker(marker) @@ -285,10 +319,18 @@ fn build_constraint( // points (an endpoint, a circle's center via the existing `-3` marker, // or a plain point) held equal on both axes. Only the DWG-native class // name and the UI entry point that produces their `refs` differ. - let point_pair_equal = |sys: &mut System, cache: &mut HashMap<_, _>, refs: &[SketchRef]| -> Vec> { + let point_pair_equal = |sys: &mut System, + cache: &mut HashMap<_, _>, + refs: &[SketchRef]| + -> Vec> { let [a, b] = refs else { return Vec::new() }; - let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; - vec![Rc::new(Equal::new(pa.x, pb.x, 1.0)), Rc::new(Equal::new(pa.y, pb.y, 1.0))] + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { + return Vec::new(); + }; + vec![ + Rc::new(Equal::new(pa.x, pb.x, 1.0)), + Rc::new(Equal::new(pa.y, pb.y, 1.0)), + ] }; match c.kind { @@ -296,62 +338,125 @@ fn build_constraint( point_pair_equal(sys, cache, &c.refs) } ConstraintKind::Horizontal => { - let Some(r) = c.refs.first() else { return Vec::new() }; - let Some(l) = whole_line(sys, cache, *r) else { return Vec::new() }; + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(l) = whole_line(sys, cache, *r) else { + return Vec::new(); + }; vec![Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))] } ConstraintKind::Vertical => { - let Some(r) = c.refs.first() else { return Vec::new() }; - let Some(l) = whole_line(sys, cache, *r) else { return Vec::new() }; + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(l) = whole_line(sys, cache, *r) else { + return Vec::new(); + }; vec![Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0))] } ConstraintKind::Parallel => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; - vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] - } - ConstraintKind::Perpendicular => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; - vec![Rc::new(PerpendicularConstraint::new(sys.store(), moving, fixed))] - } - ConstraintKind::Equal => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { - return vec![Rc::new(EqualLineLength::new(lb, la))]; - } - let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) else { return Vec::new() }; - vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] - } - ConstraintKind::EqualDistance => { - let [a, b, c2, d] = c.refs.as_slice() else { return Vec::new() }; - let (Some(pa), Some(pb), Some(pc), Some(pd)) = - (point_ref(sys, cache, *a), point_ref(sys, cache, *b), point_ref(sys, cache, *c2), point_ref(sys, cache, *d)) + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new(); }; - vec![Rc::new(EqualLineLength::new(GLine { p1: pc, p2: pd }, GLine { p1: pa, p2: pb }))] + vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] + } + ConstraintKind::Perpendicular => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + moving, + fixed, + ))] + } + ConstraintKind::Equal => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { + return vec![Rc::new(EqualLineLength::new(lb, la))]; + } + let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] + } + ConstraintKind::EqualDistance => { + let [a, b, c2, d] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb), Some(pc), Some(pd)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *b), + point_ref(sys, cache, *c2), + point_ref(sys, cache, *d), + ) else { + return Vec::new(); + }; + vec![Rc::new(EqualLineLength::new( + GLine { p1: pc, p2: pd }, + GLine { p1: pa, p2: pb }, + ))] } ConstraintKind::Colinear => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(onto), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(onto), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; // Pinning both of `moving`'s endpoints onto `onto`'s infinite // line forces the two to coincide (as long as `moving`'s own // two points stay distinct) — no dedicated "colinear" primitive // needed, `PointOnLine` applied twice does it. - vec![Rc::new(PointOnLine::new(moving.p1, onto)), Rc::new(PointOnLine::new(moving.p2, onto))] + vec![ + Rc::new(PointOnLine::new(moving.p1, onto)), + Rc::new(PointOnLine::new(moving.p2, onto)), + ] } ConstraintKind::Midpoint => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { + return Vec::new(); + }; vec![ - Rc::new(CenterOfGravity::new(p.x, vec![l.p1.x, l.p2.x], vec![0.5, 0.5])), - Rc::new(CenterOfGravity::new(p.y, vec![l.p1.y, l.p2.y], vec![0.5, 0.5])), + Rc::new(CenterOfGravity::new( + p.x, + vec![l.p1.x, l.p2.x], + vec![0.5, 0.5], + )), + Rc::new(CenterOfGravity::new( + p.y, + vec![l.p1.y, l.p2.y], + vec![0.5, 0.5], + )), ] } ConstraintKind::PointOnCurve => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(p), Some(geom)) = (point_ref(sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(p), Some(geom)) = ( + point_ref(sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { return Vec::new(); }; match geom { @@ -368,17 +473,21 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(a.circle, p, zero))] } - // `PointOnEllipse` (`ocs_gcs::constraints::conic`) isn't + // `PointOnEllipse` (`cadkernel_constraints::constraints::conic`) isn't // wired up yet — deliberately deferred alongside // ellipse-tangency, same as this module's doc comment. EntityGeom::Ellipse(_) => Vec::new(), } } ConstraintKind::Symmetric => { - let [a, b, m] = c.refs.as_slice() else { return Vec::new() }; - let (Some(pa), Some(pb), Some(mirror)) = - (point_ref(sys, cache, *a), point_ref(sys, cache, *b), whole_line(sys, cache, *m)) - else { + let [a, b, m] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb), Some(mirror)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *b), + whole_line(sys, cache, *m), + ) else { return Vec::new(); }; let pair = GLine { p1: pa, p2: pb }; @@ -388,7 +497,9 @@ fn build_constraint( ] } ConstraintKind::Fixed => { - let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(r) = c.refs.first() else { + return Vec::new(); + }; let Some(geom) = resolve_ref(document, sys, cache, *r) else { return Vec::new(); }; @@ -396,7 +507,12 @@ fn build_constraint( EntityGeom::Line(l) => { let (x1, y1, x2, y2) = { let store = sys.store(); - (store.get(l.p1.x), store.get(l.p1.y), store.get(l.p2.x), store.get(l.p2.y)) + ( + store.get(l.p1.x), + store.get(l.p1.y), + store.get(l.p2.x), + store.get(l.p2.y), + ) }; vec![ Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), @@ -408,7 +524,11 @@ fn build_constraint( EntityGeom::Circle(circ) => { let (cx, cy, r) = { let store = sys.store(); - (store.get(circ.center.x), store.get(circ.center.y), store.get(circ.rad)) + ( + store.get(circ.center.x), + store.get(circ.center.y), + store.get(circ.rad), + ) }; vec![ Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), @@ -449,7 +569,11 @@ fn build_constraint( EntityGeom::Ellipse(el) => { let (cx, cy, b) = { let store = sys.store(); - (store.get(el.center.x), store.get(el.center.y), store.get(el.radmin)) + ( + store.get(el.center.x), + store.get(el.center.y), + store.get(el.radmin), + ) }; vec![ Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), @@ -460,64 +584,106 @@ fn build_constraint( } } ConstraintKind::Distance => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let target = sys.add_param(resolved, true); vec![Rc::new(P2PDistance::new(pa, pb, target))] } ConstraintKind::Angle => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(fixed), Some(moving)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) else { return Vec::new() }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let angle = sys.add_param(resolved.to_radians(), true); vec![Rc::new(L2LAngle::new(fixed, moving, angle))] } ConstraintKind::Radius => { - let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(r) = c.refs.first() else { + return Vec::new(); + }; let Some(circle) = whole_circle(sys, cache, *r) else { return Vec::new(); }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let target = sys.add_param(resolved, true); vec![Rc::new(Equal::new(circle.rad, target, 1.0))] } // Same math as `Radius`, just `radius = target / 2` instead of // `radius = target` — `Equal`'s `ratio` param already supports - // this, matching how AutoCAD itself stores Diameter as the same + // this, matching how the native format stores Diameter as the same // `ACRADIUSDIAMETERCONSTRAINT` class with a different mode byte // rather than a distinct one (`dwg_native_constraints.rs`). ConstraintKind::Diameter => { - let Some(r) = c.refs.first() else { return Vec::new() }; + let Some(r) = c.refs.first() else { + return Vec::new(); + }; let Some(circle) = whole_circle(sys, cache, *r) else { return Vec::new(); }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let target = sys.add_param(resolved, true); vec![Rc::new(Equal::new(circle.rad, target, 0.5))] } // Signed X-only/Y-only component of the distance between two // points — `Difference::new(p1, p2, d)` is `p2 - p1 == d`, matching - // AutoCAD's own `ACDISTANCECONSTRAINT` with a fixed-direction + // the native format's `ACDISTANCECONSTRAINT` with a fixed-direction // vector rather than a distinct native class // (`dwg_native_constraints.rs`). ConstraintKind::DistanceX => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let target = sys.add_param(resolved, true); vec![Rc::new(Difference::new(pa.x, pb.x, target))] } ConstraintKind::DistanceY => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { return Vec::new() }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let target = sys.add_param(resolved, true); vec![Rc::new(Difference::new(pa.y, pb.y, target))] } ConstraintKind::Tangent => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(ga), Some(gb)) = (resolve_ref(document, sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(ga), Some(gb)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { return Vec::new(); }; // An `Arc`'s `.circle` behaves identically to a plain `Circle` @@ -527,17 +693,28 @@ fn build_constraint( match (as_circle_or_line(ga), as_circle_or_line(gb)) { (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { let store = sys.store(); - let (x1, y1, r1) = (store.get(c1.center.x), store.get(c1.center.y), store.get(c1.rad)); - let (x2, y2, r2) = (store.get(c2.center.x), store.get(c2.center.y), store.get(c2.rad)); + let (x1, y1, r1) = ( + store.get(c1.center.x), + store.get(c1.center.y), + store.get(c1.rad), + ); + let (x2, y2, r2) = ( + store.get(c2.center.x), + store.get(c2.center.y), + store.get(c2.rad), + ); let center_dist = ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt(); // One circle already sits inside the other's span, rather // than the two side by side — pick internal tangency so // the first solve doesn't have to cross the singularity // between the two tangency configurations. let internal = center_dist < (r1 - r2).abs(); - vec![Rc::new(TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, internal))] + vec![Rc::new(TangentCircumf::new( + c1.center, c2.center, c1.rad, c2.rad, internal, + ))] } - (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { let store = sys.store(); let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); @@ -565,12 +742,18 @@ fn build_constraint( // circle's center" — the same `PointOnLine` primitive // `PointOnCurve` already uses for a point-on-line case. ConstraintKind::Normal => { - let [a, b] = c.refs.as_slice() else { return Vec::new() }; - let (Some(ga), Some(gb)) = (resolve_ref(document, sys, cache, *a), resolve_ref(document, sys, cache, *b)) else { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(ga), Some(gb)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { return Vec::new(); }; match (as_circle_or_line(ga), as_circle_or_line(gb)) { - (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { vec![Rc::new(PointOnLine::new(circ.center, line))] } // Line-Line has no meaning here (that's `Perpendicular`); @@ -580,9 +763,15 @@ fn build_constraint( } } ConstraintKind::ArcLength => { - let Some(r) = c.refs.first() else { return Vec::new() }; - let Some(EntityGeom::Arc(arc)) = resolve_ref(document, sys, cache, *r) else { return Vec::new() }; - let Some(Ok(resolved)) = c.driving_param.as_ref().map(|d| d.resolve(params)) else { return Vec::new() }; + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(EntityGeom::Arc(arc)) = resolve_ref(document, sys, cache, *r) else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; let target = sys.add_param(resolved, true); vec![Rc::new(ArcLength::new(arc, target))] } @@ -590,13 +779,20 @@ fn build_constraint( } const MOVE_EPS: f64 = 1e-9; +type SolveResult = ( + Vec<(Handle, EntityType)>, + usize, + Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )>, +); -/// Rebuilds `set`'s entire `ocs_gcs::System` from current document +/// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document /// geometry, solves it, and returns the resulting entity states for every /// handle whose registered coordinates actually moved beyond floating-point -/// noise, plus the scope's total remaining degrees of freedom (summed across -/// every independent `SubSystem` — design doc §6.3's DOF badge), plus (design -/// doc §6.4, stage 11) any redundant/conflicting constraints found, resolved +/// noise, plus the scope's total remaining degrees of freedom and any +/// redundant/conflicting constraints, resolved /// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer /// to remove. `None` if nothing in the scope could be built (no constraint /// resolved to anything). DOF/conflicts are still returned even when every @@ -607,12 +803,12 @@ fn solve_scope( document: &acadrust::CadDocument, params: &ParameterTable, set: &SketchConstraintSet, -) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> { +) -> Option { let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); - // Tracks which system-level `ocs_gcs` constraint(s) came from which + // Tracks which system-level `cadkernel_constraints` constraint(s) came from which // `SketchConstraint` — a `SubSystem`'s redundant-row indices (from - // `ocs_gcs::diagnosis`) are local to that partition's own constraint + // `cadkernel_constraints::diagnosis`) are local to that partition's own constraint // list, not `set.constraints`' indices, and several kinds contribute // more than one system-level constraint per `SketchConstraint` (see // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after @@ -633,7 +829,7 @@ fn solve_scope( // Arc rules (this module's doc comment): keep every registered arc's // `start`/`end` points consistent with its center/radius/angle, - // unconditionally — planegcs itself adds these the moment an arc + // unconditionally — the kernel adds these the moment an arc // exists, not only when some `SketchConstraint` happens to reference // its endpoint. Not tracked in `owner`: these are solver-internal // bookkeeping, never redundant with anything a user-facing constraint @@ -641,14 +837,34 @@ fn solve_scope( for geom in cache.values() { let EntityGeom::Arc(arc) = geom else { continue }; let curve = Rc::new(*arc); - sys.add_constraint(Rc::new(CurveValue::new(arc.start, arc.start.x, curve.clone(), arc.start_angle))); - sys.add_constraint(Rc::new(CurveValue::new(arc.start, arc.start.y, curve.clone(), arc.start_angle))); - sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.x, curve.clone(), arc.end_angle))); - sys.add_constraint(Rc::new(CurveValue::new(arc.end, arc.end.y, curve, arc.end_angle))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.start, + arc.start.x, + curve.clone(), + arc.start_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.start, + arc.start.y, + curve.clone(), + arc.start_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.end, + arc.end.x, + curve.clone(), + arc.end_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.end, + arc.end.y, + curve, + arc.end_angle, + ))); } // Ellipse rules (same motivation as arc rules, different mechanism): - // `ocs_gcs::geo::Ellipse` stores `focus1` as an absolute point, not an + // `cadkernel_constraints::geo::Ellipse` stores `focus1` as an absolute point, not an // offset from `center`. If some constraint (e.g. Concentric) pulls only // on `center` and nothing references `focus1`/`radmin`, the solver // correctly leaves `focus1`'s *absolute* coordinates untouched — but @@ -663,10 +879,17 @@ fn solve_scope( // today). `radmin` needs no equivalent rule — as a lone scalar it isn't // coupled to `center`'s movement, so it already stays put on its own. for geom in cache.values() { - let EntityGeom::Ellipse(el) = geom else { continue }; + let EntityGeom::Ellipse(el) = geom else { + continue; + }; let (cx, cy, fx, fy) = { let store = sys.store(); - (store.get(el.center.x), store.get(el.center.y), store.get(el.focus1.x), store.get(el.focus1.y)) + ( + store.get(el.center.x), + store.get(el.center.y), + store.get(el.focus1.x), + store.get(el.focus1.y), + ) }; let dx = sys.add_param(fx - cx, true); let dy = sys.add_param(fy - cy, true); @@ -678,14 +901,12 @@ fn solve_scope( for sub in &partitions { solve_dl(sub, sys.store_mut()); } - // `System::partition`'s subsystems only include params actually - // referenced by some constraint (design doc's own note on - // `System::partition` — the driven-param fix found while building stage - // 3) — so a registered entity coordinate nothing constrains yet (e.g. a + // `System::partition` only includes parameters referenced by a + // constraint, so an otherwise registered but unconstrained coordinate (e.g. a // line's X after only a Horizontal constraint pins its Ys) is invisible // to `diagnose` entirely, undercounting DOF. Every such untouched free // param is unconstrained on its own, i.e. exactly 1 DOF each — added - // back here rather than fixed in `ocs_gcs::diagnosis`, which correctly + // back here rather than fixed in `cadkernel_constraints::diagnosis`, which correctly // has no opinion on params outside the `SubSystem` it was handed. let total_free: usize = cache .values() @@ -705,9 +926,12 @@ fn solve_scope( .sum(); let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); let mut dof = total_free.saturating_sub(touched_free); - let mut conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)> = Vec::new(); + let mut conflicts: Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )> = Vec::new(); for sub in &partitions { - let diag = ocs_gcs::diagnosis::diagnose(sub, sys.store()); + let diag = cadkernel_constraints::diagnosis::diagnose(sub, sys.store()); dof += diag.dof; if diag.redundant.is_empty() { continue; @@ -716,7 +940,9 @@ fn solve_scope( // per redundant row) — only reached when a partition is actually // over-constrained, which is rare, so this never costs anything on // the common "no redundancy" path. - for (row, kind) in ocs_gcs::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) { + for (row, kind) in + cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) + { let row_constraint = &sub.constraints()[row]; if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) { conflicts.push((id, kind)); @@ -727,10 +953,17 @@ fn solve_scope( let store = sys.store(); let mut results = Vec::new(); for (&handle, geom) in &cache { - let Some(entity) = document.get_entity(handle) else { continue }; + let Some(entity) = document.get_entity(handle) else { + continue; + }; match (entity, geom) { (EntityType::Line(l), EntityGeom::Line(g)) => { - let (x1, y1, x2, y2) = (store.get(g.p1.x), store.get(g.p1.y), store.get(g.p2.x), store.get(g.p2.y)); + let (x1, y1, x2, y2) = ( + store.get(g.p1.x), + store.get(g.p1.y), + store.get(g.p2.x), + store.get(g.p2.y), + ); if (x1 - l.start.x).abs() > MOVE_EPS || (y1 - l.start.y).abs() > MOVE_EPS || (x2 - l.end.x).abs() > MOVE_EPS @@ -745,8 +978,14 @@ fn solve_scope( } } (EntityType::Circle(c), EntityGeom::Circle(g)) => { - let (cx, cy, r) = (store.get(g.center.x), store.get(g.center.y), store.get(g.rad)); - if (cx - c.center.x).abs() > MOVE_EPS || (cy - c.center.y).abs() > MOVE_EPS || (r - c.radius).abs() > MOVE_EPS + let (cx, cy, r) = ( + store.get(g.center.x), + store.get(g.center.y), + store.get(g.rad), + ); + if (cx - c.center.x).abs() > MOVE_EPS + || (cy - c.center.y).abs() > MOVE_EPS + || (r - c.radius).abs() > MOVE_EPS { let mut updated = c.clone(); updated.center.x = cx; @@ -761,8 +1000,13 @@ fn solve_scope( // `start_point()`/`end_point()` compute them from these // same four), so nothing further needs writing back for // them specifically. - let (cx, cy, r) = (store.get(g.circle.center.x), store.get(g.circle.center.y), store.get(g.circle.rad)); - let (new_start_angle, new_end_angle) = (store.get(g.start_angle), store.get(g.end_angle)); + let (cx, cy, r) = ( + store.get(g.circle.center.x), + store.get(g.circle.center.y), + store.get(g.circle.rad), + ); + let (new_start_angle, new_end_angle) = + (store.get(g.start_angle), store.get(g.end_angle)); if (cx - a.center.x).abs() > MOVE_EPS || (cy - a.center.y).abs() > MOVE_EPS || (r - a.radius).abs() > MOVE_EPS @@ -782,7 +1026,7 @@ fn solve_scope( // Converting back to acadrust's center + major-axis-vector + // minor/major-ratio parametrization — the inverse of // `register_entity`'s `Ellipse` arm. `rad_maj_at` (already - // provided by `ocs_gcs::geo::Ellipse`) does the a²=b²+c² + // provided by `cadkernel_constraints::geo::Ellipse`) does the a²=b²+c² // algebra; only the major-axis *direction* needs deriving // here, from the (possibly moved) focus relative to center. let (cx, cy) = (store.get(g.center.x), store.get(g.center.y)); @@ -799,7 +1043,11 @@ fn solve_scope( el.major_axis.normalize() }; let new_major_axis = unit_major * major_radius; - let new_ratio = if major_radius > 1e-9 { minor_radius / major_radius } else { 0.0 }; + let new_ratio = if major_radius > 1e-9 { + minor_radius / major_radius + } else { + 0.0 + }; if (cx - el.center.x).abs() > MOVE_EPS || (cy - el.center.y).abs() > MOVE_EPS || (new_major_axis.x - el.major_axis.x).abs() > MOVE_EPS @@ -821,15 +1069,60 @@ fn solve_scope( } impl Scene { - /// Design doc §4.1's `bump_entities` hook: for every constraint scope - /// touched by `changes`, re-solve it and write back what moved. Returns + /// Rejects constraints the 2D kernel bridge cannot represent before they + /// are persisted or reported as applied. + pub(crate) fn validate_sketch_constraint( + &self, + kind: ConstraintKind, + refs: &[SketchRef], + driving_param: Option<&super::named_parameters::DrivingValue>, + ) -> Result<(), &'static str> { + if !refs_share_supported_plane(&self.document, refs) { + return Err( + "Constraint references must be supported entities on the same world-XY plane.", + ); + } + let validation_target = match driving_param { + Some(super::named_parameters::DrivingValue::Named(name)) + if !self.named_parameters.contains(name) => + { + Some(super::named_parameters::DrivingValue::Literal(1.0)) + } + value => value.cloned(), + }; + let candidate = SketchConstraint { + id: 0, + kind, + refs: refs.to_vec(), + driving_param: validation_target, + enabled: true, + }; + let mut system = System::new(); + let mut cache = HashMap::new(); + if build_constraint( + &self.document, + &mut system, + &mut cache, + &self.named_parameters, + &candidate, + ) + .is_empty() + { + return Err("The selected entities or target value do not support this constraint."); + } + Ok(()) + } + + /// Re-solves every constraint scope touched by `changes`. Returns /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. - pub(crate) fn refresh_sketch_constraints(&mut self, changes: &[(Handle, ChangeKind)]) -> Vec<(Handle, ChangeKind)> { - // Deletion policy (design doc §5.3/§12, open question 4): an erased - // entity silently takes its constraints with it — matching FreeCAD - // — rather than leaving a dangling `SketchRef` around. Done first, + pub(crate) fn refresh_sketch_constraints( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + // An erased entity takes its constraints with it instead of leaving + // dangling references. Done first, // and unconditionally over every scope (not just ones a `touched` // check would catch), so a scope left with zero constraints after // this doesn't attempt a pointless resolve below. @@ -845,7 +1138,11 @@ impl Scene { continue; } for i in 0..self.sketch_constraints.len() { - if self.sketch_constraints[i].constraints_touching(*handle).next().is_some() { + if self.sketch_constraints[i] + .constraints_touching(*handle) + .next() + .is_some() + { let scope = self.sketch_constraints[i].scope; let before = self.sketch_constraints[i].clone(); self.record_undo_sketch_constraints_before(scope, before); @@ -856,13 +1153,20 @@ impl Scene { let mut result = Vec::new(); for i in 0..self.sketch_constraints.len() { - let touched = changes - .iter() - .any(|(handle, _)| self.sketch_constraints[i].constraints_touching(*handle).next().is_some()); + let touched = changes.iter().any(|(handle, _)| { + self.sketch_constraints[i] + .constraints_touching(*handle) + .next() + .is_some() + }); if !touched { continue; } - let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.named_parameters, &self.sketch_constraints[i]) else { + let Some((solved, dof, conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + &self.sketch_constraints[i], + ) else { continue; }; self.sketch_constraints[i].dof = Some(dof); @@ -880,49 +1184,29 @@ impl Scene { result } - /// Design doc §7 open question 6, live-drag re-solve: a lighter sibling - /// of `refresh_sketch_constraints` for a grip drag's per-mouse-move - /// update — same "find touched scopes, rebuild the `ocs_gcs::System` - /// from scratch, solve" core, but: - /// - only reports what moved (`(Handle, EntityType)`, the actual new - /// state) rather than writing it into `self.document` itself. A live - /// grip drag already writes the directly-dragged handle's geometry - /// straight into the document each frame (`Scene::apply_grip`), so - /// this can read that live state via `solve_scope`'s normal document - /// read — but the caller (the drag's per-frame update in - /// `viewport.rs`) owns deciding how/when to write the *solved* - /// result back, since it also has to fold the touched handles into - /// this frame's preview/mesh/hatch refresh and the eventual - /// grip-release undo group. - /// - skips `record_undo_before` entirely: a grip drag tracks its - /// before/after through its own `grip_originals`/`grip_preview_handles` - /// arrays, committed as one `push_entity_group_history` group on - /// release, not through the recording session - /// `refresh_sketch_constraints` otherwise participates in — calling - /// `record_undo_before` here with no such session active for a grip - /// drag would either no-op uselessly or, worse, assume a recording - /// context that doesn't exist for this call path. - /// - skips the deletion-policy pass: nothing is erased mid-drag. - /// - /// Cheap no-op when there are no sketch constraints at all (the common - /// case), and per the design doc's own §4.2 architecture this still - /// does a full rebuild-and-solve per touched scope on every call — i.e. - /// every mouse-move frame during a drag that touches constrained - /// geometry. Acceptable for the sketch scales this system has been - /// exercised at so far; §7 open question 9 ("performance at scale - /// untested") already flags this as an accepted, unprofiled risk this - /// doesn't newly introduce. - pub(crate) fn solve_sketch_constraints_preview(&self, touched: &[Handle]) -> Vec<(Handle, EntityType)> { + /// Re-solves touched scopes during a grip drag without mutating the + /// document or recording undo. The caller owns preview write-back and the + /// final gesture history entry. + pub(crate) fn solve_sketch_constraints_preview( + &self, + touched: &[Handle], + ) -> Vec<(Handle, EntityType)> { if self.sketch_constraints.is_empty() || touched.is_empty() { return Vec::new(); } let mut result = Vec::new(); for set in &self.sketch_constraints { - let is_touched = touched.iter().any(|handle| set.constraints_touching(*handle).next().is_some()); + let is_touched = touched + .iter() + .any(|handle| set.constraints_touching(*handle).next().is_some()); if !is_touched { continue; } - let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, &self.named_parameters, set) else { continue }; + let Some((solved, _dof, _conflicts)) = + solve_scope(&self.document, &self.named_parameters, set) + else { + continue; + }; result.extend(solved); } result @@ -931,11 +1215,76 @@ impl Scene { #[cfg(test)] mod tests { - use super::super::sketch_constraints::{ConstraintKind, SketchConstraintSet, SketchRef, SketchScope}; + use super::super::named_parameters::DrivingValue; + use super::super::sketch_constraints::{ + ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, + }; use super::Scene; use acadrust::entities::EntityType; use acadrust::types::Vector3; + #[test] + fn validation_rejects_unsupported_entity_shapes_and_planes() { + let mut scene = Scene::new(); + let circle = scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 2.0), + )); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::Horizontal, + &[SketchRef::whole(circle)], + None + ) + .is_err()); + + let lower = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let upper = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(1.0, 0.0, 1.0), + ))); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::Parallel, + &[SketchRef::whole(lower), SketchRef::whole(upper)], + None, + ) + .is_err()); + } + + #[test] + fn validation_enforces_dimensional_value_domains() { + let mut scene = Scene::new(); + let line = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let refs = [SketchRef::point(line, 0), SketchRef::point(line, 1)]; + assert!(scene + .validate_sketch_constraint( + ConstraintKind::Distance, + &refs, + Some(&DrivingValue::Literal(-1.0)), + ) + .is_err()); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::DistanceX, + &refs, + Some(&DrivingValue::Literal(-1.0)), + ) + .is_ok()); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::DistanceX, + &refs, + Some(&DrivingValue::Literal(f64::NAN)), + ) + .is_err()); + } + // The bulk of this stage's coverage (Horizontal/Parallel/Distance/ // Coincident solving, unrelated edits not triggering a resolve) lives // in `tests/sketch_constraints_solve.rs`, which only needs `pub` API. @@ -954,7 +1303,11 @@ mod tests { ))); let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - set.add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None); + set.add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); scene.sketch_constraints.push(set); scene.begin_undo_recording(); @@ -966,27 +1319,25 @@ mod tests { l.end = Vector3::new(10.0, 6.0, 0.0); } scene.bump_entities(&[(a, super::ChangeKind::Modified)]); - let recording = scene.take_undo_recording().expect("an undo recording should still be open"); + let recording = scene + .take_undo_recording() + .expect("an undo recording should still be open"); - let (entities, _objects, _sketch_constraints) = recording.into_recorded_images(); + let (entities, _objects, _sketch_constraints, _named_parameters) = + recording.into_recorded_images(); let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); - assert!(touched.contains(&a), "the directly-edited line must be in the undo delta"); - assert!(touched.contains(&b), "the constraint-solved neighbor must ride the same undo delta"); + assert!( + touched.contains(&a), + "the directly-edited line must be in the undo delta" + ); + assert!( + touched.contains(&b), + "the constraint-solved neighbor must ride the same undo delta" + ); } - /// Audit-flagged gap: ERASE removing an entity takes its constraints with - /// it (design doc's deletion policy, above), but `sketch_constraints` - /// lives on `Scene`, outside `document`/`document.objects`, so nothing - /// used to capture that mutation for undo at all — the erased geometry - /// came back on undo, but its constraints didn't. This proves the fix at - /// the level it actually lives: `refresh_sketch_constraints`'s deletion - /// pass must record the scope's whole before-image into the same - /// `UndoRecording` the entity removal itself rides in, so one recovered - /// image is enough to restore both together (the app-level wiring that - /// turns this into one committed, one-undo-press `DeltaSnapshot` is - /// `OpenCADStudio::commit_undo_delta`/`apply_delta_state`, exercised by - /// the app, not `Scene`, so it's out of this test's reach — this covers - /// the root cause, not that outer plumbing). + /// Erasing an entity records the removed constraints in the same undo + /// transaction as the geometry. #[test] fn erasing_a_constrained_entity_records_its_scope_for_undo() { let mut scene = Scene::new(); @@ -998,42 +1349,72 @@ mod tests { Vector3::new(10.0, 0.0, 0.0), Vector3::new(20.0, 5.0, 0.0), ))); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], - None, + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + None, + ); + assert_eq!( + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 ); - assert_eq!(scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1); scene.begin_undo_recording(); scene.erase_entities(&[a]); - // The live scope must already have lost the constraint (design doc's - // deletion policy) — this ensures the test actually exercises restore, - // not a no-op. + // Ensure the test exercises restoration rather than a no-op. assert_eq!( - scene.sketch_constraint_set(SketchScope::ModelSpace).map_or(0, |s| s.constraints.len()), + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .map_or(0, |s| s.constraints.len()), 0, "the constraint touching the erased line should be gone from the live set" ); - let recording = scene.take_undo_recording().expect("an undo recording should still be open"); - let (_entities, _objects, sketch_constraints) = recording.into_recorded_images(); - assert_eq!(sketch_constraints.len(), 1, "the touched scope's before-image must be captured"); + let recording = scene + .take_undo_recording() + .expect("an undo recording should still be open"); + let (_entities, _objects, sketch_constraints, _named_parameters) = + recording.into_recorded_images(); + assert_eq!( + sketch_constraints.len(), + 1, + "the touched scope's before-image must be captured" + ); let (scope, before) = &sketch_constraints[0]; assert_eq!(*scope, SketchScope::ModelSpace); - assert_eq!(before.constraints.len(), 1, "the before-image must still hold the constraint as it was before the erase"); + assert_eq!( + before.constraints.len(), + 1, + "the before-image must still hold the constraint as it was before the erase" + ); assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident); - assert_eq!(before.constraints[0].refs, vec![SketchRef::point(a, 1), SketchRef::point(b, 0)]); + assert_eq!( + before.constraints[0].refs, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)] + ); // What `apply_delta_state` does with this on undo: install the // before-image back as the live scope state. *scene.sketch_constraint_set_mut(*scope) = before.clone(); - assert_eq!(scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1, "restoring the before-image must bring the constraint back"); + assert_eq!( + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1, + "restoring the before-image must bring the constraint back" + ); } - /// Design doc §7 open question 6 (live-drag re-solve): - /// `solve_sketch_constraints_preview` must report the constrained + /// `solve_sketch_constraints_preview` reports the constrained /// neighbor's solved position without writing anything into the /// document itself — the grip-drag caller owns applying it. #[test] @@ -1048,7 +1429,11 @@ mod tests { Vector3::new(10.0, 5.0, 0.0), ))); let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - set.add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None); + set.add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); scene.sketch_constraints.push(set); // Simulate a live grip drag: mutate `a` directly (as `apply_grip` @@ -1060,7 +1445,11 @@ mod tests { let solved = scene.solve_sketch_constraints_preview(&[a]); - assert_eq!(scene.document.get_entity(b), b_before.as_ref(), "preview must not mutate the document"); + assert_eq!( + scene.document.get_entity(b), + b_before.as_ref(), + "preview must not mutate the document" + ); // The whole scope solves together (every registered entity's params // are free, not just `b`'s — see `register_entity`'s doc comment), // so `a` itself may also have shifted slightly to reach the nearest @@ -1068,7 +1457,9 @@ mod tests { // up at from `solved` too, rather than assuming it stayed exactly // where the simulated drag put it. let line_dir = |entity: &EntityType| { - let EntityType::Line(l) = entity else { panic!("expected a Line") }; + let EntityType::Line(l) = entity else { + panic!("expected a Line") + }; (l.end.x - l.start.x, l.end.y - l.start.y) }; let dir_a = solved @@ -1076,9 +1467,15 @@ mod tests { .find(|(h, _)| *h == a) .map(|(_, e)| line_dir(e)) .unwrap_or((10.0, 6.0)); - let (_, moved_entity) = solved.iter().find(|(h, _)| *h == b).expect("b should be reported as moved"); + let (_, moved_entity) = solved + .iter() + .find(|(h, _)| *h == b) + .expect("b should be reported as moved"); let dir_b = line_dir(moved_entity); let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; - assert!(cross.abs() < 1e-6, "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}"); + assert!( + cross.abs() < 1e-6, + "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" + ); } } diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 9fb843855..1d16be722 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -153,9 +153,8 @@ pub fn glyph(family: &str, ch: char) -> Option> { } let built = sysfont::with_face_data(family, |data, index| { - let mut face = ttf_parser::Face::parse(data, index).ok()?; + let face = ttf_parser::Face::parse(data, index).ok()?; let gid = face.glyph_index(ch)?; - pin_default_variations(&mut face); let k = cap_scale(&face); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; @@ -339,12 +338,11 @@ fn build_fallback(ch: char) -> Option> { let script = crate::scene::text::web_font::script_of(ch)?; let bytes = crate::scene::text::web_font::request(script)?; let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1); - let (mut face, gid) = (0..face_count).find_map(|face_index| { + let (face, gid) = (0..face_count).find_map(|face_index| { let face = ttf_parser::Face::parse(&bytes, face_index).ok()?; let gid = face.glyph_index(ch)?; Some((face, gid)) })?; - pin_default_variations(&mut face); let k = cap_scale(&face); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let mut fl = OutlineFlattener::new(k); @@ -361,46 +359,6 @@ fn build_fallback(ch: char) -> Option> { })) } -/// A variable font (e.g. macOS's system font, San Francisco — cosmic-text's -/// own fallback search happily picks it for a codepoint no static font -/// nearby covers) needs every variation axis pinned before its outline is -/// readable: `ttf-parser` resolves a variable glyph's contours through its -/// `gvar` deltas relative to whatever coordinates are currently set on the -/// `Face`, and an unset `Face` has none — `outline_glyph` then silently -/// returns `None` (not an error) for such a glyph, which upstream code was -/// treating as "this glyph has no outline" (e.g. a space) rather than "this -/// extraction failed", producing a `Some(Glyph { strokes: vec![], .. })` -/// that renders nothing without ever reporting a problem. Setting each axis -/// to its own default value (`def_value`) reproduces the font's un-varied, -/// default instance — the same shape a non-variable static font would give. -fn pin_default_variations(face: &mut ttf_parser::Face) { - if !face.is_variable() { - return; - } - for axis in face.variation_axes() { - let _ = face.set_variation(axis.tag, axis.def_value); - } -} - -/// Outlines `gid` from an already-parsed, variation-pinned `face` — the -/// shared last step both [`build_fallback`] candidates below use. `None` -/// (not just an empty glyph) whenever `outline_glyph` itself reports no -/// contours, so a caller can tell "this font can't actually render this -/// glyph" apart from "this glyph is legitimately blank" and keep looking — -/// see [`pin_default_variations`]'s doc comment for why a resolved glyph ID -/// doesn't guarantee an outline comes back. -fn outline_from_face(face: &ttf_parser::Face, gid: ttf_parser::GlyphId, k: f32) -> Option> { - let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; - let mut fl = OutlineFlattener::new(k); - face.outline_glyph(gid, &mut fl); - fl.flush(); - if fl.contours.is_empty() { - return None; - } - let fill_tris = triangulate_contours(&fl.contours); - Some(Arc::new(Glyph { strokes: fl.contours, advance, fill_tris })) -} - #[cfg(not(target_arch = "wasm32"))] fn build_fallback(ch: char) -> Option> { use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; @@ -413,54 +371,26 @@ fn build_fallback(ch: char) -> Option> { buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None); buf.shape_until_scroll(&mut fs, false); - let mut tried: Vec = Vec::new(); for run in buf.layout_runs() { for g in run.glyphs.iter() { if g.glyph_id == 0 { continue; // .notdef — this font doesn't really cover it } - tried.push(g.font_id); let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); - let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue }; - let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { continue }; - pin_default_variations(&mut face); + let font = fs.get_font(g.font_id, g.font_weight)?; + let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; let k = cap_scale(&face); - if let Some(glyph) = outline_from_face(&face, ttf_parser::GlyphId(g.glyph_id), k) { - return Some(glyph); - } - // cosmic-text resolved a real glyph here, but this font's outline - // couldn't actually be extracted — confirmed (design doc-worthy, - // not a guess) on this dev machine for macOS's system font - // (San Francisco): it's a variable TrueType font whose `gvar` - // outline path needs base `glyf` glyph data `ttf-parser` can't - // retrieve for at least some of its accented Latin glyphs, even - // after every variation axis is pinned to its default. Rather - // than silently rendering nothing, fall through to a direct - // database scan below for any other installed font that also - // covers `ch` and can actually be outlined. - } - } - - // Second-chance search: cosmic-text's own fallback chose one font (and - // it failed to outline above); look for a different installed font that - // also covers `ch`, skipping whatever was already tried. Bounded by the - // database size, not attempted per keystroke — `fallback_glyph`'s own - // cache means this only runs once per distinct character ever asked - // for. - let candidates: Vec<(fontdb::ID, u32)> = fs - .db() - .faces() - .filter(|f| !tried.contains(&f.id)) - .map(|f| (f.id, f.index)) - .collect(); - for (id, index) in candidates { - let Some(font) = fs.get_font(id, cosmic_text::fontdb::Weight::NORMAL) else { continue }; - let Ok(mut face) = ttf_parser::Face::parse(font.data(), index) else { continue }; - let Some(gid) = face.glyph_index(ch) else { continue }; - pin_default_variations(&mut face); - let k = cap_scale(&face); - if let Some(glyph) = outline_from_face(&face, gid, k) { - return Some(glyph); + let gid = ttf_parser::GlyphId(g.glyph_id); + let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; + let mut fl = OutlineFlattener::new(k); + face.outline_glyph(gid, &mut fl); + fl.flush(); + let fill_tris = triangulate_contours(&fl.contours); + return Some(Arc::new(Glyph { + strokes: fl.contours, + advance, + fill_tris, + })); } } None @@ -526,10 +456,9 @@ fn build_shaped(_family: &str, text: &str) -> Option { let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else { continue; }; - let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { + let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else { continue; }; - pin_default_variations(&mut face); let scale = (SHAPE_FS / face.units_per_em() as f32) * px_to_9; let pen_x = (glyph.x + SHAPE_FS * glyph.x_offset) * px_to_9; let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9; @@ -596,10 +525,9 @@ fn build_shaped(family: &str, text: &str) -> Option { let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue; }; - let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { + let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else { continue; }; - pin_default_variations(&mut face); let upem_g = face.units_per_em() as f32; // Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units. let scale_g = (SHAPE_FS / upem_g) * px_to_9; diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 3f2432bad..113057a8d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -728,12 +728,7 @@ struct SelectionCanvas { crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - /// One glyph per persistent sketch constraint in the current scope - /// (design doc §6.3/§7): screen anchor + label text (its symbol, plus a - /// dimensional kind's driving value — `sketch_constraints::glyph_label`) - /// + whether `solve_scope`'s `classify_redundant` call (stage 10/11) - /// flagged this constraint as redundant/conflicting — the design doc - /// §6.3's recommended "glyph color, not wire recoloring" state cue. + /// Constraint glyph anchor, label, and conflict state for the current scope. constraint_glyphs: Vec<(Point, String, bool)>, } @@ -1630,18 +1625,15 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } - // Persistent sketch-constraint glyphs (design doc §6.3/§7): a small - // pill at each constraint's anchor point, its symbol (plus value for - // a dimensional kind) as text — visual-only for now, no hit-testing. + // Constraint glyphs are visual-only and have no hit testing. if !self.constraint_glyphs.is_empty() { const GLYPH_SIZE: f32 = 11.0; const GLYPH_PAD_X: f32 = 5.0; const GLYPH_PAD_Y: f32 = 2.0; let normal_bg = theme.palette().primary.base.color; let normal_fg = theme.palette().primary.base.text; - // Design doc §6.3's recommended color cue: a constraint - // `solve_scope` flagged as redundant/conflicting (stage 10/11's - // `classify_redundant`) gets the danger palette instead of the + // A redundant or conflicting constraint gets the danger palette + // instead of the // ordinary primary one — same information a resolver panel // would show, surfaced right on the geometry. let conflict_bg = theme.palette().danger.base.color; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 376a0d8b1..aff5ada91 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -1397,8 +1397,7 @@ impl PropertiesPanel { } /// One named-parameter row: editable name + formula, with the live- - /// resolved value (or error) shown alongside — the "AutoCAD style" the - /// Parameters section is modeled on, embedded in the panel instead of + /// resolved value (or error) shown alongside, embedded in the panel instead of /// the old separate modal. Each field commits on submit (Enter / losing /// focus), not per keystroke — `ParameterTable::set` validates /// immediately and would otherwise reject a formula mid-type (the same diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 880b87f7d..d444d8f05 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -133,9 +133,9 @@ pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> El } } -pub(super) fn group_title<'a>(title: &'static str, open: &Option, font_size: f32) -> Element<'a, Message> { +pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { if title != "Draw" || TOOLS.is_empty() { - return container(text(t!(title)).size(font_size).style(muted_text_style)) + return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); } @@ -148,7 +148,7 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option, font_s PosReport::new( PANEL_ID, button( - row![text(t!(title)).size(font_size), arrow] + row![text(t!(title)).size(9), arrow] .spacing(4) .align_y(iced::Center), ) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index d15e8f792..7ffc5ec7b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -96,12 +96,6 @@ pub struct Ribbon { tool_bar_h: Arc, /// User-chosen panel density (persisted). `Auto` sizes by window width. collapse_mode: CollapseMode, - /// Font size (px) of the caption under a large button's icon (persisted; - /// Options → Display → "Ribbon label size"). - label_font_size: i32, - /// Font size (px) of a panel's own title, shown beneath its tools - /// (persisted; Options → Display → "Ribbon section title size"). - group_title_font_size: i32, /// Set by `CollapsePanels` when the tool row is in its tight state; the mode /// selector hides itself then to give the cramped tab row its space back. collapse_tight: Arc, @@ -198,8 +192,6 @@ impl Ribbon { tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())), collapse_mode: CollapseMode::default(), collapse_tight: Arc::new(AtomicBool::new(false)), - label_font_size: 10, - group_title_font_size: 9, } } @@ -214,28 +206,6 @@ impl Ribbon { self.collapse_mode } - /// Change the icon-label font size. Persistence is handled by the caller - /// via the consolidated app config (`save_config`). - pub fn set_label_font_size(&mut self, size: i32) { - self.label_font_size = size.clamp(8, 16); - } - - /// The current icon-label font size (for saving into the app config). - pub fn label_font_size(&self) -> i32 { - self.label_font_size - } - - /// Change the panel-title font size. Persistence is handled by the - /// caller via the consolidated app config (`save_config`). - pub fn set_group_title_font_size(&mut self, size: i32) { - self.group_title_font_size = size.clamp(7, 14); - } - - /// The current panel-title font size (for saving into the app config). - pub fn group_title_font_size(&self) -> i32 { - self.group_title_font_size - } - /// Current tab-bar height as last measured by the `WrapBar` widget. fn tab_bar_height(&self) -> f32 { f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed)) @@ -633,8 +603,6 @@ impl Ribbon { &self.active_linetype, self.active_lineweight, &style_ctx, - self.label_font_size, - self.group_title_font_size, ), render_group( true, @@ -649,8 +617,6 @@ impl Ribbon { &self.active_linetype, self.active_lineweight, &style_ctx, - self.label_font_size, - self.group_title_font_size, ), collapse_button( g, @@ -665,7 +631,6 @@ impl Ribbon { &self.active_linetype, self.active_lineweight, &style_ctx, - self.label_font_size, false, ), collapse_button( @@ -681,14 +646,13 @@ impl Ribbon { &self.active_linetype, self.active_lineweight, &style_ctx, - self.label_font_size, true, ), ], } }) .collect(); - CollapsePanels::new(panels, self.collapsed_open.clone(), widgets::tool_bar_h(self.group_title_font_size as f32)) + CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) .report_height(self.tool_bar_h.clone()) .report_tight(self.collapse_tight.clone()) .mode(self.collapse_mode) @@ -1339,7 +1303,7 @@ impl Ribbon { } } -/// Render a single ribbon panel (tools + group label), `tool_bar_h(group_title_font_size)` tall. +/// Render a single ribbon panel (tools + group label), fixed `TOOL_BAR_H` tall. /// When `compact`, large tools/dropdowns are drawn as small icon columns. #[allow(clippy::too_many_arguments)] fn render_group<'a>( @@ -1355,8 +1319,6 @@ fn render_group<'a>( active_linetype: &'a str, active_lineweight: LineWeight, style_ctx: &StyleContext<'_>, - label_font_size: i32, - group_title_font_size: i32, ) -> Element<'a, Message> { let mut items_row: Vec> = Vec::new(); let mut small_buf: Vec> = Vec::new(); @@ -1373,7 +1335,6 @@ fn render_group<'a>( active_lineweight, style_ctx, compact, - label_font_size: label_font_size as f32, }; for item in &group.tools { @@ -1411,12 +1372,12 @@ fn render_group<'a>( draw_panel::group_anchor(group.title, column![ tools_el, - draw_panel::group_title(group.title, open_dd, group_title_font_size as f32), + draw_panel::group_title(group.title, open_dd), ] .align_x(iced::Center) .spacing(0) .padding([3u16, 4]) - .height(Length::Fixed(widgets::tool_bar_h(group_title_font_size as f32))) + .height(Length::Fixed(TOOL_BAR_H)) .into()) } @@ -1479,7 +1440,6 @@ fn collapse_button<'a>( active_linetype: &'a str, active_lineweight: LineWeight, style_ctx: &StyleContext<'_>, - label_font_size: i32, // When set, this is the TIGHT step (reached once even the all-collapsed row // overflows): the whole panel becomes a single non-running button — the first // tool's icon + a ▾ that opens the tools flyout. Nothing runs directly here. @@ -1520,9 +1480,7 @@ fn collapse_button<'a>( .style(button::subtle) .width(Fill) .padding([3, 5]); - // This tight-mode panel *title* (not an icon caption) keeps its own - // fixed size regardless of the icon-label font-size setting. - return automatic_large_button(localized_title, content.into(), 9.0); + return automatic_large_button(localized_title, content.into()); } // Collapsed (not yet tight): a large representative-tool face — a live button @@ -1545,7 +1503,6 @@ fn collapse_button<'a>( active_lineweight, style_ctx, compact: false, - label_font_size: label_font_size as f32, }, ) } @@ -1564,7 +1521,6 @@ fn collapse_button<'a>( active_lineweight, style_ctx, compact: false, - label_font_size: label_font_size as f32, }, ) } @@ -1589,8 +1545,7 @@ fn collapse_button<'a>( .style(button::subtle) .width(Fill) .padding([1, 4]); - // Same fixed-size opener title as the tight-mode branch above. - let opener = automatic_large_button(localized_title, opener.into(), 9.0); + let opener = automatic_large_button(localized_title, opener.into()); // The large face fills a fixed slot so a collapsed panel is shorter than a full // 3-row panel, letting `CollapsePanels` shrink the ribbon row. diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 91ad9e195..7a77c6d9d 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -56,40 +56,15 @@ pub(super) const LARGE_W: f32 = ROW_H * 2.2; const LARGE_LABEL_HPAD: f32 = 8.0; /// Large ribbon labels use at most two lines before their button grows. const LARGE_LABEL_LINES: f32 = 2.0; +const LARGE_LABEL_SIZE: f32 = 10.0; /// Width of a 1-row (small) button. pub(super) const SMALL_W: f32 = ROW_H; /// Width of the ▾ strip on a small dropdown. pub(super) const ARROW_W: f32 = ROW_H * 0.4; /// Height of the ▾ strip at the bottom of a large dropdown. pub(super) const LARGE_ARR: f32 = ROW_H * 0.55; -/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label, -/// at the default 9px group-title font size. Use [`tool_bar_h`] once the live -/// `group_title_font_size` setting is in scope — this constant is only the -/// floor `tool_bar_h` never drops below, kept as its own name for the few -/// call sites that predate the size becoming user-configurable. +/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label. pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0; - -/// The group-title label's rendered height (`group_title_font_size == 9`) -/// baked into [`TOOL_BAR_H`]'s fixed 18px allowance (6px padding + this). -const GROUP_TITLE_LABEL_H_AT_DEFAULT: f32 = 12.0; -const GROUP_TITLE_FONT_SIZE_DEFAULT: f32 = 9.0; - -/// Ribbon tool-area height for a given group-title font size. -/// -/// The panel column that holds a group's icons above its title label is a -/// fixed height (`TOOL_BAR_H`, sized for the *default* title font). Raising -/// the "Section title size" option (`RibbonConfig::group_title_font_size`, -/// `src/app/config.rs`) grows the title text without growing that fixed -/// column, so the icon row above it — which fills whatever space is left — -/// gets squeezed and its icons visibly shrink. Scaling the label's share of -/// the height with the font size (proportionally to the default 12px -/// allowance at the default 9px size) keeps the icon row's share constant -/// instead. Clamped to never go *below* `TOOL_BAR_H`, so the default and -/// smaller settings render pixel-identically to before this existed. -pub(super) fn tool_bar_h(group_title_font_size: f32) -> f32 { - let label_h = GROUP_TITLE_LABEL_H_AT_DEFAULT * (group_title_font_size / GROUP_TITLE_FONT_SIZE_DEFAULT); - (3.0 * ROW_H + 6.0 + label_h).max(TOOL_BAR_H) -} /// Height of a collapsed panel button's large representative face (big icon + /// its label). A collapsed button is this face plus the title opener, so it is /// shorter than a full 3-row panel — the ribbon height follows it down. @@ -111,11 +86,8 @@ const COMBO_PANEL_PAD: Padding = Padding { thread_local! { /// Ribbon layout runs on every pointer-driven view update. Cache the font- - /// measured width per (translated label, font size) so the automatic - /// sizing stays cheap — keyed on both since the same label text is - /// measured at more than one size (a full-density button's own caption - /// vs. a collapsed panel's smaller opener title). - static LARGE_WIDTH_CACHE: RefCell> = + /// measured width per translated label so the automatic sizing stays cheap. + static LARGE_WIDTH_CACHE: RefCell> = RefCell::new(HashMap::default()); } @@ -124,7 +96,6 @@ fn ribbon_label_bounds( label: &str, width: f32, wrapping: advanced_text::Wrapping, - label_size: f32, ) -> Size { use advanced_text::{Paragraph as _, Renderer as _}; @@ -132,7 +103,7 @@ fn ribbon_label_bounds( advanced_text::Text { content: label, bounds: Size::new(width, f32::INFINITY), - size: Pixels(label_size), + size: Pixels(LARGE_LABEL_SIZE), line_height: advanced_text::LineHeight::default(), font: renderer.default_font(), align_x: advanced_text::Alignment::Center, @@ -146,41 +117,27 @@ fn ribbon_label_bounds( paragraph.min_bounds() } -/// Widest a large button will grow to keep its label on one line before -/// falling back to two-line wrapping instead. A label pre-broken with an -/// explicit `\n` (long labels get hand-wrapped at the source, e.g. "Viewport -/// Configuration") measures its own natural per-line width regardless, so -/// this only ever engages for a plain label with no manual break. -const LARGE_LABEL_MAX_SINGLE_LINE_W: f32 = LARGE_W * 2.0; - -/// Measure the translated label at the normal button width. Prefers widening -/// the button to keep the label on one line — most labels are short enough -/// for this to be a small, one-time bump — falling back to two-line wrapping -/// only once even a single line would need excessive width. The binary -/// search uses the renderer's real font metrics, so locale and UI scale +/// Measure the translated label at the normal button width. It wraps first; +/// only labels that would need more than two lines widen their button. The +/// binary search uses the renderer's real font metrics, so locale and UI scale /// changes do not rely on character-count estimates. -fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 { +fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 { let base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0); let line_height = advanced_text::LineHeight::default() - .to_absolute(Pixels(label_size)) + .to_absolute(Pixels(LARGE_LABEL_SIZE)) .0; - - // `Wrapping::Word` (not `WordOrGlyph`): a label that doesn't fit at this - // width wraps at the next word boundary instead of splitting mid-word. - // The width check catches the rare case Word-wrapping can't help — a - // single unbreakable "word" wider than the box — so that case correctly - // falls through to the wider stage below instead of reporting a false fit. - let fits_one_line = |width: f32| { + let max_label_height = line_height * LARGE_LABEL_LINES + 0.5; + let fits = |width: f32| { let bounds = ribbon_label_bounds( renderer, label, width, advanced_text::Wrapping::Word, - label_size, ); - bounds.width <= width + 0.5 && bounds.height <= line_height + 0.5 + bounds.width <= width + 0.5 && bounds.height <= max_label_height }; - if fits_one_line(base_inner) { + + if fits(base_inner) { return LARGE_W; } @@ -189,33 +146,18 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) label, f32::INFINITY, advanced_text::Wrapping::None, - label_size, ) .width .max(base_inner); - if natural <= LARGE_LABEL_MAX_SINGLE_LINE_W { + if !fits(natural) { return (natural + LARGE_LABEL_HPAD).ceil(); } - // Long enough that a single line would make the button unreasonably - // wide — wrap to two lines instead, at the narrowest width (up to the - // same cap) that fits. - let max_label_height = line_height * LARGE_LABEL_LINES + 0.5; - let fits_two_lines = |width: f32| { - let bounds = ribbon_label_bounds( - renderer, - label, - width, - advanced_text::Wrapping::Word, - label_size, - ); - bounds.width <= width + 0.5 && bounds.height <= max_label_height - }; let mut low = base_inner; - let mut high = LARGE_LABEL_MAX_SINGLE_LINE_W; + let mut high = natural; for _ in 0..10 { let mid = (low + high) * 0.5; - if fits_two_lines(mid) { + if fits(mid) { high = mid; } else { low = mid; @@ -224,15 +166,14 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) (high + LARGE_LABEL_HPAD).ceil().max(LARGE_W) } -fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 { - let key = (label.to_string(), label_size.to_bits()); - if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(&key).copied()) { +fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 { + if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) { return width; } - let width = measure_large_width(renderer, label, label_size); + let width = measure_large_width(renderer, label); LARGE_WIDTH_CACHE.with(|cache| { - cache.borrow_mut().insert(key, width); + cache.borrow_mut().insert(label.to_string(), width); }); width } @@ -240,7 +181,6 @@ fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32 struct AutomaticLargeWidth<'a> { label: String, content: Element<'a, Message>, - label_size: f32, } impl Widget for AutomaticLargeWidth<'_> { @@ -266,7 +206,7 @@ impl Widget for AutomaticLargeWidth<'_> { renderer: &iced::Renderer, limits: &layout::Limits, ) -> layout::Node { - let width = automatic_large_width(renderer, &self.label, self.label_size); + let width = automatic_large_width(renderer, &self.label); self.content.as_widget_mut().layout( tree, renderer, @@ -346,9 +286,8 @@ impl Widget for AutomaticLargeWidth<'_> { pub(super) fn automatic_large_button<'a>( label: String, content: Element<'a, Message>, - label_size: f32, ) -> Element<'a, Message> { - Element::new(AutomaticLargeWidth { label, content, label_size }) + Element::new(AutomaticLargeWidth { label, content }) } // ── Tab-bar constants ────────────────────────────────────────────────────── @@ -693,9 +632,6 @@ pub(super) struct RenderCtx<'a> { pub style_ctx: &'a StyleContext<'a>, /// When compact, the Properties panel's Match button shrinks to a small icon. pub compact: bool, - /// Font size (px) of the caption under a large button's icon (Options → - /// Display → "Ribbon label size"). - pub label_font_size: f32, } /// A large dropdown button: the current icon on top, its ▾ directly beneath the @@ -745,7 +681,7 @@ pub(super) fn render_large_dropdown<'a>( .align_x(iced::Center) .align_y(iced::Center), text(label.clone()) - .size(ctx.label_font_size) + .size(10) .width(Fill) .align_x(iced::Center) .wrapping(advanced_text::Wrapping::Word), @@ -798,7 +734,7 @@ pub(super) fn render_large_dropdown<'a>( .width(Fill) .height(Fill); - PosReport::new(id, automatic_large_button(label, content.into(), ctx.label_font_size)) + PosReport::new(id, automatic_large_button(label, content.into())) .into() } @@ -878,7 +814,7 @@ pub(super) fn render_large<'a>( .align_x(iced::Center) .align_y(iced::Center), text(label.clone()) - .size(ctx.label_font_size) + .size(10) .width(Fill) .align_x(iced::Center) .wrapping(advanced_text::Wrapping::Word), @@ -899,7 +835,7 @@ pub(super) fn render_large<'a>( left: 4.0, }); tooltip( - automatic_large_button(label, btn.into(), ctx.label_font_size), + automatic_large_button(label, btn.into()), make_tip(tip_text), TipPos::Right, ) diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index e425621f8..296548ae5 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -125,13 +125,13 @@ impl StatusBar { // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, menu_data: StatusMenuData<'a>, - // Remaining degrees of freedom for the current sketch scope's - // persistent constraints (design doc §6.3) — `None` when the scope + // Remaining degrees of freedom for the current sketch scope. + // `None` when the scope // has no `SketchConstraintSet` yet (nothing constrained), so the // badge stays invisible until it's actually relevant. sketch_dof: Option, - // Number of redundant/conflicting constraints `solve_scope` flagged - // in the current sketch scope (design doc §6.4, stage 11) — 0 hides + // Number of redundant/conflicting constraints in the current scope. + // Zero hides // the pill entirely, so an ordinarily/fully-constrained drawing sees // no new clutter. sketch_conflicts: usize, @@ -274,11 +274,7 @@ impl StatusBar { .into(), ); } - // Design doc §6.4 (stage 11): a bounded v1 of the guided conflict - // resolver — click removes one flagged constraint at a time. See - // `OpenCADStudio::resolve_one_sketch_conflict`'s doc comment for the - // full scope-down rationale versus the design doc's named-candidate, - // cyclable-preview panel. + // Each click removes one flagged constraint. if sketch_conflicts > 0 { pills.push( tip( @@ -1082,10 +1078,7 @@ fn status_pill(label: impl Into) -> Element<'static, Message> { .into() } -/// Like [`status_pill`], but with the theme's `success` accent — design doc -/// §6.3's "simple color-coded constraint state" cue: used for the DOF badge -/// when a sketch scope has reached zero remaining degrees of freedom, so -/// "fully constrained" is visible at a glance without opening anything. +/// A success-colored status pill used when no degrees of freedom remain. fn success_pill(label: impl Into) -> Element<'static, Message> { container(text(label.into()).size(12)) .style(|theme: &Theme| { diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index 1cff42bf9..35628ae0a 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -1,6 +1,4 @@ -//! Named-parameter table editor — an in-canvas modal (Plan B), mirroring -//! `alias_editor.rs`'s buffered-rows pattern exactly. Opened by PARAMETERS -//! (`docs/named_parameters_design.md` stage 4). Rows are `(name, formula)`, +//! Named-parameter table editor using buffered `(name, formula)` rows. //! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`) //! and committed to `Scene::named_parameters` only on Apply — matching the //! alias editor's "closing discards unapplied edits" convention, which @@ -25,9 +23,7 @@ use iced::widget::tooltip::Position as TipPos; use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space}; use iced::{Background, Element, Length, Theme}; -/// Short "Kind handle" label for one referenced entity — the same handle -/// display (`0x…`) AutoCAD/this app's own conventions use, since entities -/// have no user-facing name to show instead. +/// Short "Kind handle" label for an entity without a user-facing name. fn entity_label(scene: &Scene, handle: Handle) -> String { match scene.document.get_entity(handle) { Some(EntityType::Line(_)) => format!("Line {handle}"), @@ -46,7 +42,12 @@ fn usage_lines(scene: &Scene, name: &str) -> Vec { .parameter_usage(name) .iter() .map(|u| { - let entities = u.entities.iter().map(|h| entity_label(scene, *h)).collect::>().join(", "); + let entities = u + .entities + .iter() + .map(|h| entity_label(scene, *h)) + .collect::>() + .join(", "); format!("{:?}: {entities}", u.kind) }) .collect() @@ -74,14 +75,20 @@ fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> { None => counts.push((kind, 1)), } } - let summary = counts.iter().map(|(k, n)| format!("{k} ×{n}")).collect::>().join(", "); + let summary = counts + .iter() + .map(|(k, n)| format!("{k} ×{n}")) + .collect::>() + .join(", "); let mut body = column![].spacing(2); for line in &lines { body = body.push(text(line.clone()).size(11)); } tooltip( text(summary).size(11).style(muted_style), - container(body).style(container::bordered_box).padding([4, 8]), + container(body) + .style(container::bordered_box) + .padding([4, 8]), TipPos::Top, ) .into() @@ -107,11 +114,15 @@ pub struct ParamEditorRow { const GUTTER: f32 = 16.0; fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.68)) } + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.68)), + } } fn danger_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { color: Some(theme.palette().danger.base.color) } + iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + } } /// Every buffer index sharing a (trimmed) name with at least one other @@ -130,7 +141,11 @@ pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections:: by_name.entry(name).or_default().push(idx); } } - by_name.into_values().filter(|idxs| idxs.len() > 1).flatten().collect() + by_name + .into_values() + .filter(|idxs| idxs.len() > 1) + .flatten() + .collect() } /// Builds a scratch table from every row's *current* text and resolves each @@ -184,7 +199,11 @@ fn preview(rows: &[ParamEditorRow]) -> Vec>> { /// unapplied edits to a row's name in the buffer don't retroactively /// relabel what's shown, same as the rest of this editor only takes effect /// on Apply. -pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { +pub fn view_window<'a>( + rows: &'a [ParamEditorRow], + scene: &'a Scene, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { let title = text(t!("Named Parameters")).size(15); let hint = text(t!( "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." @@ -192,17 +211,25 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra .size(11) .style(muted_style); - let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 }; + let gutter = iced::Padding { + top: 0.0, + right: GUTTER, + bottom: 0.0, + left: 0.0, + }; const NAME_WIDTH: f32 = 120.0; const VALUE_WIDTH: f32 = 100.0; const USED_BY_WIDTH: f32 = 160.0; let head = container( row![ - container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)), + container(text(t!("Name")).size(11).style(muted_style)) + .width(Length::Fixed(NAME_WIDTH)), container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width), - container(text(t!("Value")).size(11).style(muted_style)).width(Length::Fixed(VALUE_WIDTH)), - container(text(t!("Used by")).size(11).style(muted_style)).width(Length::Fixed(USED_BY_WIDTH)), + container(text(t!("Value")).size(11).style(muted_style)) + .width(Length::Fixed(VALUE_WIDTH)), + container(text(t!("Used by")).size(11).style(muted_style)) + .width(Length::Fixed(USED_BY_WIDTH)), Space::new().width(Length::Fixed(30.0)), ] .spacing(8), @@ -213,25 +240,38 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra let mut list = column![].spacing(3); for (idx, row) in rows.iter().enumerate() { let name_box = text_input(t!("name").as_ref(), &row.name) - .on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Name, value: v }) + .on_input(move |v| Message::NamedParametersInput { + idx, + field: ParamField::Name, + value: v, + }) .size(13) .padding([3, 6]) .width(Length::Fixed(NAME_WIDTH)); let formula_box = text_input(t!("formula").as_ref(), &row.formula) - .on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Formula, value: v }) + .on_input(move |v| Message::NamedParametersInput { + idx, + field: ParamField::Formula, + value: v, + }) .size(13) .padding([3, 6]) .width(sizing.width); let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() { - Some(Ok(value)) => container(text(format!("= {value:.4}")).size(12).style(muted_style)).into(), + Some(Ok(value)) => { + container(text(format!("= {value:.4}")).size(12).style(muted_style)).into() + } Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(), None => Space::new().into(), }; let used_by_cell = used_by_cell(scene, row.name.trim()); - let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) - .on_press(Message::NamedParametersRemove(idx)) - .padding([2, 6]) - .style(button::danger); + let del = button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::NamedParametersRemove(idx)) + .padding([2, 6]) + .style(button::danger); list = list.push( row![ name_box, @@ -272,7 +312,10 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra .padding(12) .width(sizing.width) .height(sizing.height) - .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() }) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) .into() } @@ -281,12 +324,18 @@ mod tests { use super::*; fn row(name: &str, formula: &str) -> ParamEditorRow { - ParamEditorRow { name: name.to_string(), formula: formula.to_string() } + ParamEditorRow { + name: name.to_string(), + formula: formula.to_string(), + } } #[test] fn preview_resolves_a_literal_and_a_cross_row_reference() { - let rows = vec![row("hole_dia", "5"), row("hole_spacing", "2 * hole_dia + 1.5")]; + let rows = vec![ + row("hole_dia", "5"), + row("hole_spacing", "2 * hole_dia + 1.5"), + ]; let results = preview(&rows); assert_eq!(results[0], Some(Ok(5.0))); assert_eq!(results[1], Some(Ok(11.5))); @@ -309,7 +358,10 @@ mod tests { let results = preview(&rows); // Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle); // row 1 (b = a) is the one that would close the loop and is rejected. - assert!(matches!(results[0], Some(Ok(_))) || matches!(&results[0], Some(Err(e)) if e.contains("not defined"))); + assert!( + matches!(results[0], Some(Ok(_))) + || matches!(&results[0], Some(Err(e)) if e.contains("not defined")) + ); assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular"))); } @@ -332,27 +384,40 @@ mod tests { let results = preview(&rows); assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate"))); assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate"))); - assert_eq!(results[2], Some(Ok(3.0)), "an unrelated row's name must not be affected"); + assert_eq!( + results[2], + Some(Ok(3.0)), + "an unrelated row's name must not be affected" + ); } #[test] fn usage_lines_names_the_constraint_kind_and_its_entities() { use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; let mut scene = Scene::new(); - let line = scene.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points( - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - ))); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(crate::scene::named_parameters::DrivingValue::Named("gap".to_string())), - ); + let line = scene.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ), + )); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(crate::scene::named_parameters::DrivingValue::Named( + "gap".to_string(), + )), + ); let lines = usage_lines(&scene, "gap"); assert_eq!(lines.len(), 1); assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}"); - assert!(lines[0].contains(&format!("{line}")), "should name the actual entity handle, got {lines:?}"); + assert!( + lines[0].contains(&format!("{line}")), + "should name the actual entity handle, got {lines:?}" + ); } #[test] diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index cdebc8d2a..44e0906a9 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -163,9 +163,6 @@ pub fn view_window<'a>( crosshair_color: Option<[u8; 3]>, crosshair_color_input: &'a str, lineweight_display_scale: i32, - ui_scale: i32, - ribbon_label_font_size: i32, - ribbon_group_title_font_size: i32, model_space: &'a crate::app::config::ModelSpaceThemeConfig, model_bg_input: &'a str, paper_bg_input: &'a str, @@ -359,16 +356,14 @@ pub fn view_window<'a>( iced::widget::checkbox(write_dwg_native_constraints) .on_toggle(Message::WriteDwgNativeConstraintsChanged) .size(15), - text(crate::t!("Write AutoCAD-compatible constraint objects on save")).size(12), + text(crate::t!("Write native constraint objects on save")).size(12), ] .spacing(8) .align_y(iced::Center), Space::new().height(6), text(crate::t!( - "Saves sketch constraints as AutoCAD/BricsCAD's own native objects too, alongside \ - this app's own format, so other CAD software recognizes them. Off by default — \ - adds some file size on every save. A drawing that already has these objects keeps \ - them up to date regardless of this setting." + "Saves sketch constraints as native drawing objects alongside this app's own format. \ + Off by default because it adds file size. Existing native objects stay synchronized." )) .size(11) .width(sizing.width), @@ -596,24 +591,6 @@ pub fn view_window<'a>( }); let mut display = column![ - text(crate::t!("UI Scale")).size(15), - Space::new().height(10), - row![ - text(crate::t!("Overall size")).size(12).width(150), - slider(50..=200, ui_scale.clamp(50, 200), Message::UiScaleChanged) - .step(5) - .width(Fill), - text(format!("{}%", ui_scale.clamp(50, 200))).size(11).width(44), - ] - .spacing(12) - .align_y(iced::Center), - Space::new().height(8), - text(crate::t!( - "Scales the whole interface at once — every panel, dialog, the ribbon, and their text — rather than one widget's font size. Takes effect immediately." - )) - .size(11) - .width(sizing.width), - Space::new().height(24), text(crate::tr!("options", "theme-section")).size(15), Space::new().height(10), row![ @@ -871,56 +848,6 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), - ) - .push(Space::new().height(24)) - .push(text(crate::t!("Ribbon")).size(15)) - .push(Space::new().height(10)) - .push( - row![ - text(crate::t!("Icon label size")).size(12).width(140), - slider( - 8..=16, - ribbon_label_font_size.clamp(8, 16), - Message::RibbonLabelFontSizeChanged, - ) - .step(1) - .width(Fill), - text(format!("{}px", ribbon_label_font_size.clamp(8, 16))) - .size(11) - .width(44), - ] - .spacing(10) - .align_y(iced::Center), - ) - .push(Space::new().height(6)) - .push( - text(crate::t!("Font size of the caption under each ribbon button's icon.")) - .size(11) - .width(sizing.width), - ) - .push(Space::new().height(10)) - .push( - row![ - text(crate::t!("Section title size")).size(12).width(140), - slider( - 7..=14, - ribbon_group_title_font_size.clamp(7, 14), - Message::RibbonGroupTitleFontSizeChanged, - ) - .step(1) - .width(Fill), - text(format!("{}px", ribbon_group_title_font_size.clamp(7, 14))) - .size(11) - .width(44), - ] - .spacing(10) - .align_y(iced::Center), - ) - .push(Space::new().height(6)) - .push( - text(crate::t!("Font size of each panel's own title (Draw, Modify, Layers, …).")) - .size(11) - .width(sizing.width), ); let display_element = display.spacing(0).width(sizing.width); diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index 0c326db38..6741759c3 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -1,15 +1,8 @@ -// Stage 3 of docs/parametric_system_design.md's staged plan: -// `refresh_sketch_constraints` wired into `Scene::bump_entities` (§4.1), -// exercised against an in-memory-only `SketchConstraintSet` (no save/load — -// that's stage 4). Verifies: an edit to one constrained entity moves its -// constrained neighbors, produces the expected change list, and an -// unrelated edit doesn't touch anything (§4.3's "what does NOT trigger"). - +use acadrust::entities::EntityType; +use acadrust::types::{Handle, Vector3}; use OpenCADStudio::scene::named_parameters::DrivingValue; use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use OpenCADStudio::scene::{ChangeKind, Scene}; -use acadrust::entities::EntityType; -use acadrust::types::{Handle, Vector3}; fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( @@ -32,7 +25,9 @@ fn set_line_end(scene: &mut Scene, handle: Handle, end: Vector3) { } fn add_circle(scene: &mut Scene, cx: f64, cy: f64, radius: f64) -> Handle { - scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius(Vector3::new(cx, cy, 0.0), radius))) + scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::new(cx, cy, 0.0), radius), + )) } fn circle_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { @@ -42,13 +37,22 @@ fn circle_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { } } -fn add_arc(scene: &mut Scene, cx: f64, cy: f64, radius: f64, start_angle: f64, end_angle: f64) -> Handle { - scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( - Vector3::new(cx, cy, 0.0), - radius, - start_angle, - end_angle, - ))) +fn add_arc( + scene: &mut Scene, + cx: f64, + cy: f64, + radius: f64, + start_angle: f64, + end_angle: f64, +) -> Handle { + scene.add_entity(EntityType::Arc( + acadrust::entities::Arc::from_center_radius_angles( + Vector3::new(cx, cy, 0.0), + radius, + start_angle, + end_angle, + ), + )) } fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { @@ -66,11 +70,13 @@ fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) { } fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { - scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes( - Vector3::new(cx, cy, 0.0), - Vector3::new(major_axis.0, major_axis.1, 0.0), - ratio, - ))) + scene.add_entity(EntityType::Ellipse( + acadrust::entities::Ellipse::from_center_axes( + Vector3::new(cx, cy, 0.0), + Vector3::new(major_axis.0, major_axis.1, 0.0), + ratio, + ), + )) } fn ellipse_geom(scene: &Scene, handle: Handle) -> (Vector3, Vector3, f64) { @@ -85,7 +91,13 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { let mut scene = Scene::new(); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Horizontal, vec![SketchRef::whole(line)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(line)], + None, + ); // Drag the endpoint off-axis — a real edit, the kind of thing a Move // command or grip-drag commit would do. @@ -93,7 +105,10 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { scene.bump_entities(&[(line, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, line); - assert!((start.y - end.y).abs() < 1e-6, "line should have been re-leveled: start={start:?} end={end:?}"); + assert!( + (start.y - end.y).abs() < 1e-6, + "line should have been re-leveled: start={start:?} end={end:?}" + ); // A line is 4 raw coordinates; Horizontal removes exactly 1 (y1 == y2), // leaving 3 — not 1: the two X coordinates are registered but never @@ -102,8 +117,14 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { // live-testing this stage: `System::partition`'s subsystems only see // constraint-referenced params, so an untouched one was invisible to // `diagnose` entirely before this was fixed). - let dof = scene.sketch_constraint_set(SketchScope::ModelSpace).and_then(|s| s.dof); - assert_eq!(dof, Some(3), "DOF should count the two untouched X coordinates too"); + let dof = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .and_then(|s| s.dof); + assert_eq!( + dof, + Some(3), + "DOF should count the two untouched X coordinates too" + ); } #[test] @@ -112,7 +133,13 @@ fn parallel_constraint_rotates_the_other_line_when_one_moves() { let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); // Rotate `a`; `b` (parallel to it) should follow. set_line_end(&mut scene, a, Vector3::new(10.0, 6.0, 0.0)); @@ -124,7 +151,10 @@ fn parallel_constraint_rotates_the_other_line_when_one_moves() { let dir_b = ((b2.x - b1.x), (b2.y - b1.y)); // Parallel: cross product of directions is ~0. let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; - assert!(cross.abs() < 1e-6, "lines should be parallel after the solve: dir_a={dir_a:?} dir_b={dir_b:?}"); + assert!( + cross.abs() < 1e-6, + "lines should be parallel after the solve: dir_a={dir_a:?} dir_b={dir_b:?}" + ); } #[test] @@ -132,18 +162,23 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( let mut scene = Scene::new(); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(DrivingValue::Literal(20.0)), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(20.0)), + ); set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 scene.bump_entities(&[(line, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, line); let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 20.0).abs() < 1e-6, "length should have been solved to the driving value, got {len}"); + assert!( + (len - 20.0).abs() < 1e-6, + "length should have been solved to the driving value, got {len}" + ); } /// `named_parameters_design.md` stage 3: a `Distance` constraint's driving @@ -153,20 +188,28 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( fn distance_constraint_resolves_a_named_parameter_reference() { let mut scene = Scene::new(); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); - scene.named_parameters_mut().set("target_len", "20").unwrap(); + scene + .named_parameters_mut() + .set("target_len", "20") + .unwrap(); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(DrivingValue::Named("target_len".to_string())), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("target_len".to_string())), + ); set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 scene.bump_entities(&[(line, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, line); let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 20.0).abs() < 1e-6, "length should have resolved the named parameter, got {len}"); + assert!( + (len - 20.0).abs() < 1e-6, + "length should have resolved the named parameter, got {len}" + ); // Editing the parameter itself and touching the scope again should // ripple through, same as editing a literal driving value would. @@ -174,7 +217,10 @@ fn distance_constraint_resolves_a_named_parameter_reference() { scene.bump_entities(&[(line, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, line); let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter value, got {len}"); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the redefined parameter value, got {len}" + ); } /// A `driving_param` referencing a named parameter that doesn't (or no @@ -189,11 +235,13 @@ fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked let mut scene = Scene::new(); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(DrivingValue::Named("does_not_exist".to_string())), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("does_not_exist".to_string())), + ); let edited_end = Vector3::new(5.0, 5.0, 0.0); set_line_end(&mut scene, line, edited_end); @@ -211,7 +259,13 @@ fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() { // deliberately not tangent yet. let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Tangent, vec![SketchRef::whole(circle), SketchRef::whole(line)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(circle), SketchRef::whole(line)], + None, + ); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); let (center, radius) = circle_geom(&scene, circle); @@ -229,13 +283,23 @@ fn tangent_constraint_between_two_circles_solves_to_external_tangency() { let a = add_circle(&mut scene, 0.0, 0.0, 3.0); let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5 - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Tangent, vec![SketchRef::whole(a), SketchRef::whole(b)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); let (ca, ra) = circle_geom(&scene, a); let (cb, rb) = circle_geom(&scene, b); let center_dist = ((ca.x - cb.x).powi(2) + (ca.y - cb.y).powi(2)).sqrt(); - assert!((center_dist - (ra + rb)).abs() < 1e-6, "circles should sit exactly (r1+r2) apart after solving: dist={center_dist} r1+r2={}", ra + rb); + assert!( + (center_dist - (ra + rb)).abs() < 1e-6, + "circles should sit exactly (r1+r2) apart after solving: dist={center_dist} r1+r2={}", + ra + rb + ); } #[test] @@ -244,11 +308,13 @@ fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() { let a = add_line(&mut scene, 0.0, 0.0, 5.0, 0.0); let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + None, + ); // Move a's end away — b's start should follow to stay coincident. set_line_end(&mut scene, a, Vector3::new(8.0, 3.0, 0.0)); @@ -256,7 +322,10 @@ fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() { let (_, a_end) = line_endpoints(&scene, a); let (b_start, _) = line_endpoints(&scene, b); - assert!((a_end.x - b_start.x).abs() < 1e-6 && (a_end.y - b_start.y).abs() < 1e-6, "a.end={a_end:?} b.start={b_start:?}"); + assert!( + (a_end.x - b_start.x).abs() < 1e-6 && (a_end.y - b_start.y).abs() < 1e-6, + "a.end={a_end:?} b.start={b_start:?}" + ); } #[test] @@ -265,63 +334,106 @@ fn unrelated_entity_edit_does_not_touch_constrained_geometry() { let line = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); // deliberately not horizontal let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Horizontal, vec![SketchRef::whole(line)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(line)], + None, + ); let (start_before, end_before) = line_endpoints(&scene, line); set_line_end(&mut scene, unrelated, Vector3::new(200.0, 150.0, 0.0)); scene.bump_entities(&[(unrelated, ChangeKind::Modified)]); let (start_after, end_after) = line_endpoints(&scene, line); - assert_eq!(start_before, start_after, "unrelated edit must not trigger a re-solve of unrelated geometry"); + assert_eq!( + start_before, start_after, + "unrelated edit must not trigger a re-solve of unrelated geometry" + ); assert_eq!(end_before, end_after); } #[test] fn erasing_a_constrained_entity_drops_its_constraints_instead_of_dangling() { - // Design doc §5.3/§12 (open question 4): deletion policy is "silently - // drop", matching FreeCAD, rather than leaving a `SketchRef` pointing - // at a handle that no longer resolves. + // Deleting an entity drops its constraints instead of leaving dangling refs. let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint - let parallel_id = - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None); - let horizontal_id = scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Horizontal, vec![SketchRef::whole(c)], None); + let parallel_id = scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + let horizontal_id = scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(c)], None); scene.erase_entities(&[a]); - let set = scene.sketch_constraint_set(SketchScope::ModelSpace).expect("scope still exists"); - assert!(set.get(parallel_id).is_none(), "the Parallel constraint referencing the erased line must be gone"); - assert!(set.get(horizontal_id).is_some(), "an unrelated constraint on a different entity must survive"); + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("scope still exists"); + assert!( + set.get(parallel_id).is_none(), + "the Parallel constraint referencing the erased line must be gone" + ); + assert!( + set.get(horizontal_id).is_some(), + "an unrelated constraint on a different entity must survive" + ); // And the surviving constraint set still functions normally afterward. set_line_end(&mut scene, c, Vector3::new(30.0, 25.0, 0.0)); scene.bump_entities(&[(c, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, c); - assert!((start.y - end.y).abs() < 1e-6, "remaining Horizontal constraint should still solve: start={start:?} end={end:?}"); + assert!( + (start.y - end.y).abs() < 1e-6, + "remaining Horizontal constraint should still solve: start={start:?} end={end:?}" + ); } #[test] fn copying_two_constrained_entities_carries_their_constraint_along() { - // Design doc §5.3/§12 (open question 3, resolved): a constraint entirely - // between the copied entities should follow the copy. + // A constraint entirely between copied entities follows the copy. let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); // Solve once so the source pair is actually parallel before copying. scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); - let constraints_before = scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(); + let constraints_before = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(); - let transform = OpenCADStudio::command::EntityTransform::Translate(glam::DVec3::new(100.0, 100.0, 0.0)); + let transform = + OpenCADStudio::command::EntityTransform::Translate(glam::DVec3::new(100.0, 100.0, 0.0)); let new_handles = scene.copy_entities(&[a, b], &transform); assert_eq!(new_handles.len(), 2); let (new_a, new_b) = (new_handles[0], new_handles[1]); - let constraints_after = scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(); - assert_eq!(constraints_after, constraints_before + 1, "the copy should have gained exactly one new Parallel constraint"); + let constraints_after = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(); + assert_eq!( + constraints_after, + constraints_before + 1, + "the copy should have gained exactly one new Parallel constraint" + ); // Break the copy's parallelism, then confirm its own (copied) constraint // re-solves it — proof this is a real, independent constraint on the @@ -342,7 +454,10 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { let odir_a = (oa2.x - oa1.x, oa2.y - oa1.y); let odir_b = (ob2.x - ob1.x, ob2.y - ob1.y); let ocross = odir_a.0 * odir_b.1 - odir_a.1 * odir_b.0; - assert!(ocross.abs() < 1e-6, "the original pair should remain parallel too"); + assert!( + ocross.abs() < 1e-6, + "the original pair should remain parallel too" + ); } // The "one edit that ripples through a constraint still records as one undo @@ -352,11 +467,10 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { #[test] fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { - // Design doc §6.4 (stage 11): `solve_scope` classifies the redundant row - // `ocs_gcs::diagnosis::diagnose` flags and resolves it back to the + // `solve_scope` resolves the redundant row back to the // `ConstraintId` a `ConflictResolverPanel` would name. + use cadkernel_constraints::diagnosis::RedundancyKind; use OpenCADStudio::scene::sketch_constraints::ConstraintKind; - use ocs_gcs::diagnosis::RedundancyKind; let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); @@ -366,30 +480,52 @@ fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { scene.bump_entities(&[(a, ChangeKind::Modified)]); - let set = scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); - assert_eq!(set.conflicts.len(), 1, "exactly one of the two identical constraints should be flagged"); + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap(); + assert_eq!( + set.conflicts.len(), + 1, + "exactly one of the two identical constraints should be flagged" + ); let (flagged_id, kind) = set.conflicts[0]; - assert!(flagged_id == first || flagged_id == second, "the flagged id should be one of the two duplicates"); + assert!( + flagged_id == first || flagged_id == second, + "the flagged id should be one of the two duplicates" + ); assert_eq!(kind, RedundancyKind::Redundant); } #[test] fn two_conflicting_distance_targets_are_reported_as_conflicting() { + use cadkernel_constraints::diagnosis::RedundancyKind; use OpenCADStudio::scene::sketch_constraints::ConstraintKind; - use ocs_gcs::diagnosis::RedundancyKind; let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - let first = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(DrivingValue::Literal(20.0))); - let second = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(DrivingValue::Literal(50.0))); + let first = set.add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + Some(DrivingValue::Literal(20.0)), + ); + let second = set.add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + Some(DrivingValue::Literal(50.0)), + ); scene.bump_entities(&[(a, ChangeKind::Modified)]); - let set = scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap(); assert_eq!(set.conflicts.len(), 1); let (flagged_id, kind) = set.conflicts[0]; - assert!(flagged_id == first || flagged_id == second, "the flagged id should be one of the two conflicting Distance constraints"); + assert!( + flagged_id == first || flagged_id == second, + "the flagged id should be one of the two conflicting Distance constraints" + ); assert_eq!(kind, RedundancyKind::Conflicting); } @@ -411,20 +547,28 @@ fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() { let a = add_circle(&mut scene, 0.0, 0.0, 3.0); let b = add_circle(&mut scene, 5.0, 5.0, 1.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Concentric, - vec![SketchRef::center(a), SketchRef::center(b)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(a), SketchRef::center(b)], + None, + ); set_circle_center(&mut scene, a, Vector3::new(2.0, -3.0, 0.0)); scene.bump_entities(&[(a, ChangeKind::Modified)]); let (ca, ra) = circle_geom(&scene, a); let (cb, rb) = circle_geom(&scene, b); - assert!((ca.x - cb.x).abs() < 1e-6 && (ca.y - cb.y).abs() < 1e-6, "centers should coincide: a={ca:?} b={cb:?}"); + assert!( + (ca.x - cb.x).abs() < 1e-6 && (ca.y - cb.y).abs() < 1e-6, + "centers should coincide: a={ca:?} b={cb:?}" + ); // Radii are untouched by Concentric — only centers move. - assert!((ra - 3.0).abs() < 1e-6 && (rb - 1.0).abs() < 1e-6, "radii must not change: ra={ra} rb={rb}"); + assert!( + (ra - 3.0).abs() < 1e-6 && (rb - 1.0).abs() < 1e-6, + "radii must not change: ra={ra} rb={rb}" + ); } #[test] @@ -433,11 +577,13 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() { let circle = add_circle(&mut scene, 0.0, 0.0, 4.0); let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(circle)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(circle)], + None, + ); set_circle_center(&mut scene, circle, Vector3::new(-6.0, 9.0, 0.0)); scene.bump_entities(&[(circle, ChangeKind::Modified)]); @@ -456,11 +602,13 @@ fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Colinear, - vec![SketchRef::whole(a), SketchRef::whole(b)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Colinear, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); scene.bump_entities(&[(b, ChangeKind::Modified)]); @@ -469,8 +617,16 @@ fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { // Both of b's endpoints should land on a's infinite line: the signed // area of (a2-a1) × (bN-a1) is ~0 for a point exactly on that line. let area = |p: Vector3| (a2.x - a1.x) * (p.y - a1.y) - (a2.y - a1.y) * (p.x - a1.x); - assert!(area(b1).abs() < 1e-5, "b.start should land on a's line, area={}", area(b1)); - assert!(area(b2).abs() < 1e-5, "b.end should land on a's line, area={}", area(b2)); + assert!( + area(b1).abs() < 1e-5, + "b.start should land on a's line, area={}", + area(b1) + ); + assert!( + area(b2).abs() < 1e-5, + "b.end should land on a's line, area={}", + area(b2) + ); } #[test] @@ -479,11 +635,13 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { let base = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Midpoint, - vec![SketchRef::point(marker, 0), SketchRef::whole(base)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Midpoint, + vec![SketchRef::point(marker, 0), SketchRef::whole(base)], + None, + ); set_line_end(&mut scene, base, Vector3::new(10.0, 8.0, 0.0)); scene.bump_entities(&[(base, ChangeKind::Modified)]); @@ -504,8 +662,19 @@ fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(fixed_line)], None); - set.add(ConstraintKind::Coincident, vec![SketchRef::point(fixed_line, 1), SketchRef::point(moving_line, 0)], None); + set.add( + ConstraintKind::Fixed, + vec![SketchRef::whole(fixed_line)], + None, + ); + set.add( + ConstraintKind::Coincident, + vec![ + SketchRef::point(fixed_line, 1), + SketchRef::point(moving_line, 0), + ], + None, + ); let (before_start, before_end) = line_endpoints(&scene, fixed_line); @@ -516,11 +685,15 @@ fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { scene.bump_entities(&[(moving_line, ChangeKind::Modified)]); let (after_start, after_end) = line_endpoints(&scene, fixed_line); - assert_eq!(before_start, after_start, "Fixed entity's start must not move"); + assert_eq!( + before_start, after_start, + "Fixed entity's start must not move" + ); assert_eq!(before_end, after_end, "Fixed entity's end must not move"); let (moving_start, _) = line_endpoints(&scene, moving_line); assert!( - (moving_start.x - before_end.x).abs() < 1e-6 && (moving_start.y - before_end.y).abs() < 1e-6, + (moving_start.x - before_end.x).abs() < 1e-6 + && (moving_start.y - before_end.y).abs() < 1e-6, "moving_line's start should have been pulled back to fixed_line's (unmoved) end" ); } @@ -531,18 +704,23 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { let circle = add_circle(&mut scene, 0.0, 0.0, 5.0); let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], + None, + ); scene.bump_entities(&[(marker, ChangeKind::Modified)]); let (center, radius) = circle_geom(&scene, circle); let (marker_start, _) = line_endpoints(&scene, marker); let dist = ((marker_start.x - center.x).powi(2) + (marker_start.y - center.y).powi(2)).sqrt(); - assert!((dist - radius).abs() < 1e-5, "point should land on the circumference: dist={dist} radius={radius}"); + assert!( + (dist - radius).abs() < 1e-5, + "point should land on the circumference: dist={dist} radius={radius}" + ); } #[test] @@ -553,11 +731,18 @@ fn equal_distance_constraint_matches_a_second_point_pairs_separation() { // Tracked pair: starts at some other separation, should be pulled to 6. let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::EqualDistance, - vec![SketchRef::point(b, 0), SketchRef::point(b, 1), SketchRef::point(a, 0), SketchRef::point(a, 1)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::EqualDistance, + vec![ + SketchRef::point(b, 0), + SketchRef::point(b, 1), + SketchRef::point(a, 0), + SketchRef::point(a, 1), + ], + None, + ); scene.bump_entities(&[(b, ChangeKind::Modified)]); @@ -565,7 +750,10 @@ fn equal_distance_constraint_matches_a_second_point_pairs_separation() { let (b1, b2) = line_endpoints(&scene, b); let dist_a = ((a2.x - a1.x).powi(2) + (a2.y - a1.y).powi(2)).sqrt(); let dist_b = ((b2.x - b1.x).powi(2) + (b2.y - b1.y).powi(2)).sqrt(); - assert!((dist_a - dist_b).abs() < 1e-6, "the two pairs' separations should match: dist_a={dist_a} dist_b={dist_b}"); + assert!( + (dist_a - dist_b).abs() < 1e-6, + "the two pairs' separations should match: dist_a={dist_a} dist_b={dist_b}" + ); } #[test] @@ -587,13 +775,25 @@ fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { // deterministic outcome this test actually wants to check). set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); - set.add(ConstraintKind::Symmetric, vec![SketchRef::center(a), SketchRef::center(b), SketchRef::whole(axis)], None); + set.add( + ConstraintKind::Symmetric, + vec![ + SketchRef::center(a), + SketchRef::center(b), + SketchRef::whole(axis), + ], + None, + ); scene.bump_entities(&[(b, ChangeKind::Modified)]); let (ca, _) = circle_geom(&scene, a); let (cb, _) = circle_geom(&scene, b); - assert_eq!(ca, Vector3::new(3.0, 4.0, 0.0), "Fixed a's center must not have moved"); + assert_eq!( + ca, + Vector3::new(3.0, 4.0, 0.0), + "Fixed a's center must not have moved" + ); // With both a and the axis pinned, b's center has a unique valid // position left: a's exact mirror across the Y axis, (-3, 4). assert!( @@ -612,15 +812,20 @@ fn radius_constraint_on_an_arc_solves_to_the_target() { let mut scene = Scene::new(); let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Radius, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(7.5)), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(7.5)), + ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); let (_, radius) = arc_geom(&scene, arc); - assert!((radius - 7.5).abs() < 1e-6, "arc radius should have solved to the driven target: got {radius}"); + assert!( + (radius - 7.5).abs() < 1e-6, + "arc radius should have solved to the driven target: got {radius}" + ); } #[test] @@ -629,7 +834,13 @@ fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() { let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Tangent, vec![SketchRef::whole(arc), SketchRef::whole(line)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(arc), SketchRef::whole(line)], + None, + ); scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); let (center, radius) = arc_geom(&scene, arc); @@ -646,11 +857,13 @@ fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() { let arc = add_arc(&mut scene, 0.0, 0.0, 4.0, 0.0, std::f64::consts::PI); let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Concentric, - vec![SketchRef::center(arc), SketchRef::center(circle)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(arc), SketchRef::center(circle)], + None, + ); scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); let (arc_center, _) = arc_geom(&scene, arc); @@ -682,11 +895,13 @@ fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() { let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); // Move the arc directly (bypassing the solver), same pattern as the // equivalent Circle/Ellipse CenterPoint tests — the constraint is a @@ -719,7 +934,11 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); - set.add(ConstraintKind::Coincident, vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], None); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + None, + ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); let (center, radius, start_angle, _) = { @@ -728,7 +947,11 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv other => panic!("expected an Arc, got {other:?}"), } }; - let recomputed_start = Vector3::new(center.x + radius * start_angle.cos(), center.y + radius * start_angle.sin(), 0.0); + let recomputed_start = Vector3::new( + center.x + radius * start_angle.cos(), + center.y + radius * start_angle.sin(), + 0.0, + ); let actual_start = arc_start_point(&scene, arc); assert!( (recomputed_start.x - actual_start.x).abs() < 1e-6 && (recomputed_start.y - actual_start.y).abs() < 1e-6, @@ -746,17 +969,22 @@ fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], + None, + ); scene.bump_entities(&[(marker, ChangeKind::Modified)]); let (center, radius) = arc_geom(&scene, arc); let (p, _) = line_endpoints(&scene, marker); let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt(); - assert!((dist - radius).abs() < 1e-6, "point should land exactly on the arc's circle: dist={dist} radius={radius}"); + assert!( + (dist - radius).abs() < 1e-6, + "point should land exactly on the arc's circle: dist={dist} radius={radius}" + ); } #[test] @@ -770,12 +998,23 @@ fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); - set.add(ConstraintKind::Coincident, vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], None); - set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0))); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + None, + ); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(6.0)), + ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); let (_, radius) = arc_geom(&scene, arc); - assert!((radius - 6.0).abs() < 1e-6, "Radius constraint should still hold: got {radius}"); + assert!( + (radius - 6.0).abs() < 1e-6, + "Radius constraint should still hold: got {radius}" + ); let actual_start = arc_start_point(&scene, arc); assert!( (actual_start.x - 9.0).abs() < 1e-6 && (actual_start.y - -4.0).abs() < 1e-6, @@ -784,7 +1023,7 @@ fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { } // Phase 2 of the same plan: `Diameter`/`DistanceX`/`DistanceY` reuse -// existing `ocs_gcs` primitives (`Equal`'s `ratio`, `Difference`) with no +// existing `cadkernel_constraints` primitives (`Equal`'s `ratio`, `Difference`) with no // new solver-crate code. #[test] @@ -792,15 +1031,20 @@ fn diameter_constraint_drives_the_radius_to_half_the_target() { let mut scene = Scene::new(); let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Diameter, - vec![SketchRef::whole(circle)], - Some(DrivingValue::Literal(16.0)), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Diameter, + vec![SketchRef::whole(circle)], + Some(DrivingValue::Literal(16.0)), + ); scene.bump_entities(&[(circle, ChangeKind::Modified)]); let (_, radius) = circle_geom(&scene, circle); - assert!((radius - 8.0).abs() < 1e-6, "radius should be half the diameter target: got {radius}"); + assert!( + (radius - 8.0).abs() < 1e-6, + "radius should be half the diameter target: got {radius}" + ); } #[test] @@ -810,15 +1054,21 @@ fn distance_x_constraint_pins_only_the_x_component() { // distinguishable from a plain Distance/Horizontal outcome. let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::DistanceX, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(DrivingValue::Literal(10.0)), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); scene.bump_entities(&[(line, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, line); - assert!((end.x - start.x - 10.0).abs() < 1e-6, "x component should equal the target: dx={}", end.x - start.x); + assert!( + (end.x - start.x - 10.0).abs() < 1e-6, + "x component should equal the target: dx={}", + end.x - start.x + ); } // Phase 3 of the same plan: `Normal` reduces to "line passes through the @@ -832,7 +1082,13 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() { // A horizontal line well clear of the circle's center. let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Normal, vec![SketchRef::whole(circle), SketchRef::whole(line)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Normal, + vec![SketchRef::whole(circle), SketchRef::whole(line)], + None, + ); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); let (center, _) = circle_geom(&scene, circle); @@ -840,7 +1096,10 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() { let line_dir = (p2.x - p1.x, p2.y - p1.y); let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; - assert!(signed_dist.abs() < 1e-6, "the circle's center should lie on the (infinite) line after solving: dist={signed_dist}"); + assert!( + signed_dist.abs() < 1e-6, + "the circle's center should lie on the (infinite) line after solving: dist={signed_dist}" + ); } // Phase 4a of the same plan: `ArcLength` registers an arc's @@ -855,14 +1114,28 @@ fn arc_length_constraint_drives_the_swept_length_to_the_target() { let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(5.0))); - set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(10.0))); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(5.0)), + ); + set.add( + ConstraintKind::ArcLength, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(10.0)), + ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); let (radius, start_angle, end_angle) = arc_angles(&scene, arc); - assert!((radius - 5.0).abs() < 1e-6, "Radius constraint should still hold: got {radius}"); + assert!( + (radius - 5.0).abs() < 1e-6, + "Radius constraint should still hold: got {radius}" + ); let length = radius * (end_angle - start_angle); - assert!((length - 10.0).abs() < 1e-5, "swept length should equal the ArcLength target: got {length}"); + assert!( + (length - 10.0).abs() < 1e-5, + "swept length should equal the ArcLength target: got {length}" + ); } // Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- @@ -878,12 +1151,17 @@ fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together( let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], - None, - ); - scene.bump_entities(&[(ellipse, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[ + (ellipse, ChangeKind::Modified), + (circle, ChangeKind::Modified), + ]); let (ellipse_center, _, _) = ellipse_geom(&scene, ellipse); let (circle_center, _) = circle_geom(&scene, circle); @@ -899,11 +1177,13 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + None, + ); // Move the ellipse directly (bypassing the solver) before the first // solve, same as the equivalent Circle test — the constraint is a @@ -925,7 +1205,7 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { #[test] fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { // The riskiest part of Ellipse support: converting acadrust's center + - // major-axis-*vector* + minor/major ratio into `ocs_gcs`'s center + + // major-axis-*vector* + minor/major ratio into `cadkernel_constraints`'s center + // focus + minor-radius, then back, for a major axis that ISN'T // axis-aligned (an axis-aligned one wouldn't exercise the direction // math at all). A solve with nothing actually pulling on it should @@ -934,17 +1214,28 @@ fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { let ellipse = add_ellipse(&mut scene, 2.0, -3.0, (4.0, 3.0), 0.6); // major radius 5, tilted let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], - None, - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); let (center, major_axis, ratio) = ellipse_geom(&scene, ellipse); - assert!((center.x - 2.0).abs() < 1e-6 && (center.y - -3.0).abs() < 1e-6, "center should not have drifted: {center:?}"); - assert!((major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 3.0).abs() < 1e-6, "major axis should not have drifted: {major_axis:?}"); - assert!((ratio - 0.6).abs() < 1e-6, "minor/major ratio should not have drifted: {ratio}"); + assert!( + (center.x - 2.0).abs() < 1e-6 && (center.y - -3.0).abs() < 1e-6, + "center should not have drifted: {center:?}" + ); + assert!( + (major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 3.0).abs() < 1e-6, + "major axis should not have drifted: {major_axis:?}" + ); + assert!( + (ratio - 0.6).abs() < 1e-6, + "minor/major ratio should not have drifted: {ratio}" + ); } #[test] @@ -954,7 +1245,7 @@ fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_m // ALREADY concentric with the ellipse, so Concentric's equations start // out satisfied and the ellipse's center never actually has to move — // it doesn't exercise the bug at all. Here the circle starts elsewhere, - // forcing a real center move, which is exactly when `ocs_gcs::geo:: + // forcing a real center move, which is exactly when `cadkernel_constraints::geo:: // Ellipse`'s absolute `focus1` point used to get left behind: `center` // moved to meet the circle, `focus1` didn't (nothing else referenced // it), so the derived major-axis direction/length and minor/major @@ -965,12 +1256,17 @@ fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_m let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (4.0, 0.0), 0.5); let circle = add_circle(&mut scene, 10.0, 7.0, 1.5); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], - None, - ); - scene.bump_entities(&[(ellipse, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[ + (ellipse, ChangeKind::Modified), + (circle, ChangeKind::Modified), + ]); let (ellipse_center, major_axis, ratio) = ellipse_geom(&scene, ellipse); let (circle_center, _) = circle_geom(&scene, circle); @@ -986,7 +1282,10 @@ fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_m (major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 0.0).abs() < 1e-6, "major axis must not rotate or resize just because the center moved: {major_axis:?}" ); - assert!((ratio - 0.5).abs() < 1e-6, "minor/major ratio must not change just because the center moved: {ratio}"); + assert!( + (ratio - 0.5).abs() < 1e-6, + "minor/major ratio must not change just because the center moved: {ratio}" + ); } #[test] @@ -997,7 +1296,11 @@ fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); - set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], None); + set.add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + None, + ); let before = ellipse_geom(&scene, ellipse); @@ -1008,7 +1311,10 @@ fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { scene.bump_entities(&[(line, ChangeKind::Modified)]); let after = ellipse_geom(&scene, ellipse); - assert_eq!(before, after, "Fixed ellipse must not move at all (center, major axis, or ratio)"); + assert_eq!( + before, after, + "Fixed ellipse must not move at all (center, major axis, or ratio)" + ); let (start, _) = line_endpoints(&scene, line); assert!( (start.x - before.0.x).abs() < 1e-6 && (start.y - before.0.y).abs() < 1e-6, @@ -1021,13 +1327,19 @@ fn distance_y_constraint_pins_only_the_y_component() { let mut scene = Scene::new(); let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( - ConstraintKind::DistanceY, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(DrivingValue::Literal(-3.0)), - ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(-3.0)), + ); scene.bump_entities(&[(line, ChangeKind::Modified)]); let (start, end) = line_endpoints(&scene, line); - assert!((end.y - start.y - -3.0).abs() < 1e-6, "y component should equal the (signed) target: dy={}", end.y - start.y); + assert!( + (end.y - start.y - -3.0).abs() < 1e-6, + "y component should equal the (signed) target: dy={}", + end.y - start.y + ); } diff --git a/tests/sketch_constraints_xrecord_roundtrip.rs b/tests/sketch_constraints_xrecord_roundtrip.rs index 5446a8959..435218ac4 100644 --- a/tests/sketch_constraints_xrecord_roundtrip.rs +++ b/tests/sketch_constraints_xrecord_roundtrip.rs @@ -1,16 +1,15 @@ -// Stage 2 of docs/parametric_system_design.md's staged plan: prove a -// SketchConstraintSet, serialized into an XRecord Chunk entry hung off a +// Proves a SketchConstraintSet serialized into an XRecord entry // block record's extension dictionary, survives a real save/reload through -// both supported file formats byte-for-byte. Everything after this stage -// (save/load integration, the "lazy" persistence model) is gated on this -// test passing — see the design doc §8 stage 2 and §7 open question 1. +// both supported file formats byte-for-byte. -use OpenCADStudio::scene::named_parameters::DrivingValue; -use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchConstraintSet, SketchRef, SketchScope}; -use OpenCADStudio::scene::Scene; use acadrust::entities::EntityType; use acadrust::objects::{XRecordEntry, XRecordValue}; use acadrust::types::{Handle, Vector3}; +use OpenCADStudio::scene::named_parameters::DrivingValue; +use OpenCADStudio::scene::sketch_constraints::{ + ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, +}; +use OpenCADStudio::scene::Scene; const RECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS_TEST"; @@ -18,16 +17,30 @@ fn sample_set(scope: SketchScope) -> SketchConstraintSet { let mut set = SketchConstraintSet::new(scope); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(Handle::new(101), 0), SketchRef::point(Handle::new(102), 1)], + vec![ + SketchRef::point(Handle::new(101), 0), + SketchRef::point(Handle::new(102), 1), + ], None, ); - set.add(ConstraintKind::Distance, vec![SketchRef::whole(Handle::new(103))], Some(DrivingValue::Literal(25.0))); + set.add( + ConstraintKind::Distance, + vec![SketchRef::whole(Handle::new(103))], + Some(DrivingValue::Literal(25.0)), + ); set.add( ConstraintKind::Angle, - vec![SketchRef::whole(Handle::new(104)), SketchRef::whole(Handle::new(105))], + vec![ + SketchRef::whole(Handle::new(104)), + SketchRef::whole(Handle::new(105)), + ], Some(DrivingValue::Literal(45.0)), ); - set.add(ConstraintKind::Radius, vec![SketchRef::whole(Handle::new(106))], Some(DrivingValue::Literal(3.5))); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(Handle::new(106))], + Some(DrivingValue::Literal(3.5)), + ); set } @@ -35,13 +48,22 @@ fn sample_set(scope: SketchScope) -> SketchConstraintSet { /// dictionary, round-trips the whole document through `ext` (`"dxf"` or /// anything else for DWG), and returns the `SketchConstraintSet` read back /// out of the reloaded document. -fn roundtrip_through(scene: &Scene, owner: Handle, set: &SketchConstraintSet, ext: &str) -> SketchConstraintSet { +fn roundtrip_through( + scene: &Scene, + owner: Handle, + set: &SketchConstraintSet, + ext: &str, +) -> SketchConstraintSet { let mut doc = scene.document.clone(); let bytes = bincode::serialize(set).expect("serialize SketchConstraintSet"); doc.ensure_xrecord(owner, RECORD_KEY); - let record = doc.xrecord_mut(owner, RECORD_KEY).expect("xrecord_mut after ensure_xrecord"); - record.entries.push(XRecordEntry::new(310, XRecordValue::Chunk(bytes))); + let record = doc + .xrecord_mut(owner, RECORD_KEY) + .expect("xrecord_mut after ensure_xrecord"); + record + .entries + .push(XRecordEntry::new(310, XRecordValue::Chunk(bytes))); let saved = OpenCADStudio::io::save_to_bytes(&doc, ext, doc.version) .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); @@ -60,14 +82,18 @@ fn roundtrip_through(scene: &Scene, owner: Handle, set: &SketchConstraintSet, ex }) .unwrap_or_else(|| panic!("no Chunk entry survived the {ext} round-trip")); - bincode::deserialize::(&chunk).unwrap_or_else(|e| panic!("deserialize round-tripped {ext} blob: {e}")) + bincode::deserialize::(&chunk) + .unwrap_or_else(|e| panic!("deserialize round-tripped {ext} blob: {e}")) } #[test] fn sketch_constraint_set_survives_a_dxf_roundtrip() { let scene = Scene::new(); let owner = scene.document.header.model_space_block_handle; - assert!(!owner.is_null(), "a fresh document must already have a model-space block handle"); + assert!( + !owner.is_null(), + "a fresh document must already have a model-space block handle" + ); let original = sample_set(SketchScope::ModelSpace); let restored = roundtrip_through(&scene, owner, &original, "dxf"); From 5f3edf45ef5c8aea703950fb7e4d3d13b462b80f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 10:06:51 +0300 Subject: [PATCH 33/99] Simplify Modify ribbon extensions --- build.rs | 22 ---- src/app/commands/blocks.rs | 13 ++ src/app/commands/styleprops.rs | 17 ++- src/app/commands/view.rs | 31 +++++ src/app/update/command.rs | 6 +- src/ui/ribbon/draw_panel.rs | 70 +++++++++-- src/ui/ribbon/mod.rs | 11 ++ src/ui/ribbon/modify_panel.rs | 117 +++++++++++++++++- src/ui/ribbon/modify_tools/01_setbylayer.rs | 6 - src/ui/ribbon/modify_tools/02_lengthen.rs | 6 - src/ui/ribbon/modify_tools/03_pedit.rs | 6 - src/ui/ribbon/modify_tools/04_splinedit.rs | 6 - src/ui/ribbon/modify_tools/05_hatchedit.rs | 6 - src/ui/ribbon/modify_tools/06_align.rs | 6 - src/ui/ribbon/modify_tools/07_break.rs | 6 - src/ui/ribbon/modify_tools/08_breakatpoint.rs | 6 - src/ui/ribbon/modify_tools/09_join.rs | 6 - src/ui/ribbon/modify_tools/10_reverse.rs | 6 - src/ui/ribbon/modify_tools/11_ncopy.rs | 6 - src/ui/ribbon/modify_tools/12_overkill.rs | 6 - src/ui/ribbon/modify_tools/13_draworder.rs | 6 - 21 files changed, 248 insertions(+), 117 deletions(-) delete mode 100644 src/ui/ribbon/modify_tools/01_setbylayer.rs delete mode 100644 src/ui/ribbon/modify_tools/02_lengthen.rs delete mode 100644 src/ui/ribbon/modify_tools/03_pedit.rs delete mode 100644 src/ui/ribbon/modify_tools/04_splinedit.rs delete mode 100644 src/ui/ribbon/modify_tools/05_hatchedit.rs delete mode 100644 src/ui/ribbon/modify_tools/06_align.rs delete mode 100644 src/ui/ribbon/modify_tools/07_break.rs delete mode 100644 src/ui/ribbon/modify_tools/08_breakatpoint.rs delete mode 100644 src/ui/ribbon/modify_tools/09_join.rs delete mode 100644 src/ui/ribbon/modify_tools/10_reverse.rs delete mode 100644 src/ui/ribbon/modify_tools/11_ncopy.rs delete mode 100644 src/ui/ribbon/modify_tools/12_overkill.rs delete mode 100644 src/ui/ribbon/modify_tools/13_draworder.rs diff --git a/build.rs b/build.rs index 85ce9861f..fd50c581c 100644 --- a/build.rs +++ b/build.rs @@ -4,7 +4,6 @@ use std::path::Path; fn main() { - generate_modify_tools(); let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); let app_version = if parts.len() == 3 && parts[0].len() == 4 @@ -82,24 +81,3 @@ fn main() { } } } - -fn generate_modify_tools() { - let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); - let directory = root.join("src/ui/ribbon/modify_tools"); - println!("cargo:rerun-if-changed=src/ui/ribbon/modify_tools"); - let mut paths: Vec<_> = match std::fs::read_dir(&directory) { - Ok(entries) => entries.map(|entry| entry.expect("Modify tool entry").path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "rs")).collect(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(error) => panic!("Cannot read Modify tools: {error}"), - }; - paths.sort(); - let mut source = String::from("pub(super) const TOOLS: &[Tool] = &[\n"); - for path in paths { - println!("cargo:rerun-if-changed={}", path.display()); - source.push_str(&format!("include!({:?}),\n", path.to_string_lossy())); - } - source.push_str("];\n"); - let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); - std::fs::write(output.join("modify_panel_tools.rs"), source).expect("Write Modify tool registry"); -} diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 50f4aedbc..5e368f4cc 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -713,6 +713,19 @@ mod tests { app } + #[test] + fn ncopy_without_preselection_gathers_objects() { + let mut app = fresh_app(); + let _ = app.run_command_line("NCOPY"); + assert_eq!( + app.tabs[app.active_tab] + .active_cmd + .as_ref() + .map(|active| active.name()), + Some("SELECT") + ); + } + /// Run one command line and return only the command-line text it appended. fn run_capture(app: &mut OpenCADStudio, cmd: &str) -> String { let start = app.command_line.history.len(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 7ab8c22f1..fe427276b 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2997,6 +2997,22 @@ mod tests { app } + #[test] + fn selection_commands_gather_instead_of_using_the_whole_drawing() { + for command in ["SETBYLAYER", "OVERKILL"] { + let mut app = fresh_app(); + let _ = app.run_command_line(command); + assert_eq!( + app.tabs[app.active_tab] + .active_cmd + .as_ref() + .map(|active| active.name()), + Some("SELECT"), + "{command} should gather an explicit selection" + ); + } + } + #[test] fn test_cad_selection_and_model_space_sysvars() { let mut app = fresh_app(); @@ -3209,4 +3225,3 @@ mod scale_validation_tests { } } } - diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 609681141..5a7249d6f 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -2341,4 +2341,35 @@ mod tests { let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!(hatch_sort > line_sort); } + + #[test] + fn draworder_mode_aliases_route_directly() { + let mut app = fresh_app(); + let i = app.active_tab; + let line = app.tabs[i] + .scene + .add_entity_clone(EntityType::Line(Default::default())); + let hatch = app.tabs[i] + .scene + .add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i] + .scene + .replace_selection(std::iter::once(hatch).collect()); + let _ = app.run_command_line("DRAWORDER_FRONT"); + let entries = effective_sort_map(&app); + assert!( + entries.get(&hatch.value()).copied().unwrap_or(hatch.value()) + > entries.get(&line.value()).copied().unwrap_or(line.value()) + ); + + app.tabs[i] + .scene + .replace_selection(std::iter::once(hatch).collect()); + let _ = app.run_command_line("DRAWORDER_ABOVE"); + assert!(app.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "DRAWORDER" && command.needs_entity_pick())); + } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index e82e3cc98..c455e14e3 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -890,9 +890,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_command_escape(&mut self) -> Task { - if self.ribbon.escape_extension() { - return Task::none(); - } + if self.ribbon.escape_extension() { + return Task::none(); + } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. self.snap_override_popup = None; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index a4d30522a..6ef8bf31d 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -127,13 +127,27 @@ struct Panel { } const PANELS: &[Panel] = &[ - Panel { id: PANEL_ID, title_id: TITLE_ID, title: "Draw", tools: TOOLS }, - Panel { id: "modify_extension", title_id: "modify_extension_title", title: "Modify", tools: super::modify_panel::TOOLS }, + Panel { + id: PANEL_ID, + title_id: TITLE_ID, + title: "Draw", + tools: TOOLS, + }, + Panel { + id: "modify_extension", + title_id: "modify_extension_title", + title: "Modify", + tools: super::modify_panel::TOOLS, + }, ]; fn panel_for_dropdown(id: &str) -> Option { PANELS.iter().copied().find(|panel| { - panel.id == id || panel.tools.iter().any(|tool| tool.command == id && !tool.options.is_empty()) + panel.id == id + || panel + .tools + .iter() + .any(|tool| tool.command == id && !tool.options.is_empty()) }) } @@ -146,7 +160,10 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> { } pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { - let Some(panel) = PANELS.iter().find(|panel| panel.title == title && !panel.tools.is_empty()) else { + let Some(panel) = PANELS + .iter() + .find(|panel| panel.title == title && !panel.tools.is_empty()) + else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); @@ -160,9 +177,12 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Ele PosReport::new( panel.id, button( - row![PosReport::new(panel.title_id, text(t!(title)).size(9)), arrow] - .spacing(4) - .align_y(iced::Center), + row![ + PosReport::new(panel.title_id, text(t!(title)).size(9)), + arrow + ] + .spacing(4) + .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(panel.id.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) @@ -225,8 +245,6 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element || tools.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, |tool| tool.options.len() as f32 * OPTION_HEIGHT, ); - let ordered: Vec<&Tool> = tools.iter().filter(|tool| tool.options.is_empty()) - .chain(tools.iter().filter(|tool| !tool.options.is_empty())).collect(); let contents: Element<'static, Message> = if let Some(tool) = option_tool { column( tool.options @@ -248,13 +266,17 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .into() } else { column( - ordered + tools .chunks(cols) .map(|tools| { row(tools .iter() .map(|tool| { - tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command), panel.id) + tool_button( + tool, + ribbon.active_tool.as_deref() == Some(tool.command), + panel.id, + ) }) .collect::>()) .spacing(GAP) @@ -275,3 +297,29 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element }); dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) } + +#[cfg(test)] +mod tests { + use super::{parent_panel, PANEL_ID, TOOLS}; + + #[test] + fn extension_submenus_resolve_to_their_parent_panel() { + assert_eq!(parent_panel("REVCLOUD"), Some(PANEL_ID)); + assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension")); + assert_eq!(parent_panel("unknown"), None); + } + + #[test] + fn split_tools_follow_plain_tools() { + let first_split = TOOLS + .iter() + .position(|tool| !tool.options.is_empty()) + .unwrap_or(TOOLS.len()); + assert!(TOOLS[..first_split] + .iter() + .all(|tool| tool.options.is_empty())); + assert!(TOOLS[first_split..] + .iter() + .all(|tool| !tool.options.is_empty())); + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index feed284f9..5dc37ad6b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1600,6 +1600,17 @@ mod tests { assert!(!is_active_tool("BLOCKPALETTE", &None, &off)); } + #[test] + fn escape_returns_from_an_extension_submenu_then_closes_the_panel() { + let mut ribbon = Ribbon::default(); + ribbon.open_dropdown = Some("DRAWORDER_FRONT".to_string()); + + assert!(ribbon.escape_extension()); + assert_eq!(ribbon.open_dropdown.as_deref(), Some("modify_extension")); + assert!(ribbon.escape_extension()); + assert_eq!(ribbon.open_dropdown, None); + } + /// Reproducible element-construction benchmark for the ribbon view. Run with: /// `cargo test --lib --release -- --ignored --nocapture bench_ribbon_view_construction` /// diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index d04410b12..835e03c27 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -1,4 +1,117 @@ -//! Independently registered commands in the Modify extension. use super::draw_panel::Tool; -include!(concat!(env!("OUT_DIR"), "/modify_panel_tools.rs")); +pub(super) const TOOLS: &[Tool] = &[ + Tool { + command: "SETBYLAYER", + label: "Set to ByLayer", + icon: include_bytes!("../../../assets/icons/modify_setbylayer.svg"), + options: &[], + }, + Tool { + command: "LENGTHEN", + label: "Lengthen", + icon: include_bytes!("../../../assets/icons/modify_lengthen.svg"), + options: &[], + }, + Tool { + command: "PEDIT", + label: "Edit Polyline", + icon: include_bytes!("../../../assets/icons/modify_pedit.svg"), + options: &[], + }, + Tool { + command: "SPLINEDIT", + label: "Edit Spline", + icon: include_bytes!("../../../assets/icons/modify_splinedit.svg"), + options: &[], + }, + Tool { + command: "HATCHEDIT", + label: "Edit Hatch", + icon: include_bytes!("../../../assets/icons/modify_hatchedit.svg"), + options: &[], + }, + Tool { + command: "ALIGN", + label: "Align", + icon: include_bytes!("../../../assets/icons/modify_align.svg"), + options: &[], + }, + Tool { + command: "BREAK", + label: "Break", + icon: include_bytes!("../../../assets/icons/modify_break.svg"), + options: &[], + }, + Tool { + command: "BREAKATPOINT", + label: "Break at Point", + icon: include_bytes!("../../../assets/icons/modify_breakatpoint.svg"), + options: &[], + }, + Tool { + command: "JOIN", + label: "Join", + icon: include_bytes!("../../../assets/icons/modify_join.svg"), + options: &[], + }, + Tool { + command: "REVERSE", + label: "Reverse", + icon: include_bytes!("../../../assets/icons/modify_reverse.svg"), + options: &[], + }, + Tool { + command: "NCOPY", + label: "Copy Nested Objects", + icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"), + options: &[], + }, + Tool { + command: "OVERKILL", + label: "Delete Duplicate Objects", + icon: include_bytes!("../../../assets/icons/modify_overkill.svg"), + options: &[], + }, + Tool { + command: "DRAWORDER_FRONT", + label: "Draw Order", + icon: include_bytes!("../../../assets/icons/modify_draworder.svg"), + options: &[ + ("DRAWORDER_FRONT", "Bring to Front"), + ("DRAWORDER_BACK", "Send to Back"), + ("DRAWORDER_ABOVE", "Bring Above Objects"), + ("DRAWORDER_UNDER", "Send Under Objects"), + ], + }, +]; + +#[cfg(test)] +mod tests { + use super::TOOLS; + + #[test] + fn every_tool_command_is_registered() { + let registered = crate::command::all_registered_command_names(); + for command in TOOLS + .iter() + .flat_map(|tool| std::iter::once(tool.command).chain(tool.options.iter().map(|o| o.0))) + { + assert!(registered.contains(&command), "{command} is not registered"); + } + } + + #[test] + fn split_tools_follow_plain_tools() { + let first_split = TOOLS + .iter() + .position(|tool| !tool.options.is_empty()) + .unwrap_or(TOOLS.len()); + assert!(TOOLS[..first_split] + .iter() + .all(|tool| tool.options.is_empty())); + assert!(TOOLS[first_split..] + .iter() + .all(|tool| !tool.options.is_empty())); + } +} diff --git a/src/ui/ribbon/modify_tools/01_setbylayer.rs b/src/ui/ribbon/modify_tools/01_setbylayer.rs deleted file mode 100644 index 953ee34ed..000000000 --- a/src/ui/ribbon/modify_tools/01_setbylayer.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "SETBYLAYER", - label: "Set to ByLayer", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_setbylayer.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/02_lengthen.rs b/src/ui/ribbon/modify_tools/02_lengthen.rs deleted file mode 100644 index 384429fb5..000000000 --- a/src/ui/ribbon/modify_tools/02_lengthen.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "LENGTHEN", - label: "Lengthen", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_lengthen.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/03_pedit.rs b/src/ui/ribbon/modify_tools/03_pedit.rs deleted file mode 100644 index 7847b06e9..000000000 --- a/src/ui/ribbon/modify_tools/03_pedit.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "PEDIT", - label: "Edit Polyline", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_pedit.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/04_splinedit.rs b/src/ui/ribbon/modify_tools/04_splinedit.rs deleted file mode 100644 index 435434b33..000000000 --- a/src/ui/ribbon/modify_tools/04_splinedit.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "SPLINEDIT", - label: "Edit Spline", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_splinedit.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/05_hatchedit.rs b/src/ui/ribbon/modify_tools/05_hatchedit.rs deleted file mode 100644 index 477696833..000000000 --- a/src/ui/ribbon/modify_tools/05_hatchedit.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "HATCHEDIT", - label: "Edit Hatch", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_hatchedit.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/06_align.rs b/src/ui/ribbon/modify_tools/06_align.rs deleted file mode 100644 index 7f982c718..000000000 --- a/src/ui/ribbon/modify_tools/06_align.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "ALIGN", - label: "Align", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_align.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/07_break.rs b/src/ui/ribbon/modify_tools/07_break.rs deleted file mode 100644 index 1f130b827..000000000 --- a/src/ui/ribbon/modify_tools/07_break.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "BREAK", - label: "Break", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_break.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/08_breakatpoint.rs b/src/ui/ribbon/modify_tools/08_breakatpoint.rs deleted file mode 100644 index 298b28025..000000000 --- a/src/ui/ribbon/modify_tools/08_breakatpoint.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "BREAKATPOINT", - label: "Break at Point", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_breakatpoint.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/09_join.rs b/src/ui/ribbon/modify_tools/09_join.rs deleted file mode 100644 index 65d447e95..000000000 --- a/src/ui/ribbon/modify_tools/09_join.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "JOIN", - label: "Join", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_join.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/10_reverse.rs b/src/ui/ribbon/modify_tools/10_reverse.rs deleted file mode 100644 index 7ce5c6d9e..000000000 --- a/src/ui/ribbon/modify_tools/10_reverse.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "REVERSE", - label: "Reverse", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_reverse.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/11_ncopy.rs b/src/ui/ribbon/modify_tools/11_ncopy.rs deleted file mode 100644 index e926ce540..000000000 --- a/src/ui/ribbon/modify_tools/11_ncopy.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "NCOPY", - label: "Copy Nested Objects", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_ncopy.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/12_overkill.rs b/src/ui/ribbon/modify_tools/12_overkill.rs deleted file mode 100644 index 0c7f90e0e..000000000 --- a/src/ui/ribbon/modify_tools/12_overkill.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "OVERKILL", - label: "Delete Duplicate Objects", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_overkill.svg")), - options: &[], -} diff --git a/src/ui/ribbon/modify_tools/13_draworder.rs b/src/ui/ribbon/modify_tools/13_draworder.rs deleted file mode 100644 index ecd78fbdf..000000000 --- a/src/ui/ribbon/modify_tools/13_draworder.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "DRAWORDER_FRONT", - label: "Draw Order", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/modify_draworder.svg")), - options: &[("DRAWORDER_FRONT", "Bring to Front"), ("DRAWORDER_BACK", "Send to Back"), ("DRAWORDER_ABOVE", "Bring Above Objects"), ("DRAWORDER_UNDER", "Send Under Objects")], -} From 9da2a36efa849cb866fa3636f0bfdc0a438d7b14 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:22:20 +0300 Subject: [PATCH 34/99] Create regions from exact closed profiles and connected selected edges --- src/app/commands/draw.rs | 44 ++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index cf848ff7b..d3f73afb9 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1114,8 +1114,7 @@ impl OpenCADStudio { return Some(self.fit_spline()); } - // REGION — convert selected closed boundaries (closed polylines / - // circles) into Region entities (one wire loop each). + // REGION — convert exact closed planar profiles into regions. "REGION" | "REG" => { if self.tabs[i].scene.selected_entities().is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1129,12 +1128,7 @@ impl OpenCADStudio { let mut regions = Vec::new(); let mut sources = Vec::new(); for (handle, e) in self.tabs[i].scene.selected_entities().iter() { - let supported = matches!( - e, - acadrust::EntityType::LwPolyline(pl) - if pl.is_closed && pl.vertices.len() >= 3 - ) || matches!(e, acadrust::EntityType::Circle(_)); - if supported { + if !matches!(e, acadrust::EntityType::Region(_)) { let Some((plane, loops, true)) = crate::scene::model::presspull_model::profile_geometry(e) else { @@ -1154,6 +1148,40 @@ impl OpenCADStudio { sources.push(*handle); } } + // Assemble remaining selected open edges on the active plane. + // Face discovery and exact curve reconstruction reuse the + // same kernel-backed boundary path used by area selection. + let working_plane = self.tabs[i].ucs_xform().working_plane(); + let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i].scene + .selected_entities().iter().map(|(handle, _)| *handle).collect(); + let mut boundary_sources = self.tabs[i].scene + .boundary_sources_on_plane(working_plane, 1.0e-6); + boundary_sources.retain(|handle, _| { + selected_handles.contains(handle) && !sources.contains(handle) + }); + let plane = cadkernel::space::Plane::from_axes( + working_plane.origin.to_array(), + working_plane.x.to_array(), + working_plane.y.to_array(), + ); + for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) { + let paths = crate::scene::exact_hatch_paths( + std::slice::from_ref(&ring), &[true], &boundary_sources, 1.0e-6, + ); + let Some(path) = paths.first() else { continue; }; + let Some(curves) = path.edges.iter() + .map(crate::entities::hatch::edge_curve).collect::>>() + else { continue; }; + let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) + else { continue; }; + let mut region = Region::new(); + region.point_of_reference = Vector3::new( + plane.origin[0], plane.origin[1], plane.origin[2], + ); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); + sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); + } if regions.is_empty() { self.command_line .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); From 9948309f5cad418b90d12599db37c4df90ec4308 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:22:35 +0300 Subject: [PATCH 35/99] Accept planar and pointed helices with bounded turn counts --- src/modules/draw/draw/helix.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs index fbeb2ca63..000c79e8c 100644 --- a/src/modules/draw/draw/helix.rs +++ b/src/modules/draw/draw/helix.rs @@ -122,7 +122,7 @@ impl HelixCommand { .iter() .map(|point| Vector3::new(point[0], point[1], point[2])) .collect(); - spline.flags.planar = false; + spline.flags.planar = height == 0.0; spline.flags.rational = nurbs.is_rational(); if spline.flags.rational { spline.weights = nurbs.weights().to_vec(); @@ -262,7 +262,7 @@ impl CadCommand for HelixCommand { } else { distance }; - if radius > EPSILON { + if radius >= 0.0 { self.base_radius = radius; self.top_radius = radius; self.step = Step::TopRadius; @@ -272,21 +272,20 @@ impl CadCommand for HelixCommand { Step::TopRadius | Step::TopDiameter => { let local = self.plane.vector_to_local(point - self.center); let distance = local.x.hypot(local.y); - self.top_radius = if self.step == Step::TopDiameter { + let top_radius = if self.step == Step::TopDiameter { distance * 0.5 } else { distance }; - self.step = Step::Final; + if self.base_radius > 0.0 || top_radius > 0.0 { + self.top_radius = top_radius; + self.step = Step::Final; + } CmdResult::NeedPoint } Step::Final => { let height = (point - self.center).dot(self.plane.z); - if height.abs() <= EPSILON { - CmdResult::NeedPoint - } else { - self.commit(height, self.plane.z) - } + self.commit(height, self.plane.z) } Step::AxisEndpoint => { let vector = point - self.center; @@ -310,6 +309,9 @@ impl CadCommand for HelixCommand { CmdResult::NeedPoint } Step::TopRadius | Step::TopDiameter => { + if self.base_radius == 0.0 { + return CmdResult::NeedPoint; + } self.top_radius = self.base_radius; self.step = Step::Final; CmdResult::NeedPoint @@ -382,7 +384,7 @@ impl CadCommand for HelixCommand { } Step::BaseRadius => { let radius = Self::parse_value(value)?; - if radius > EPSILON { + if radius >= 0.0 { self.base_radius = radius; self.top_radius = radius; self.step = Step::TopRadius; @@ -391,7 +393,7 @@ impl CadCommand for HelixCommand { } Step::BaseDiameter => { let diameter = Self::parse_value(value)?; - if diameter > EPSILON { + if diameter >= 0.0 { self.base_radius = diameter * 0.5; self.top_radius = self.base_radius; self.step = Step::TopRadius; @@ -400,7 +402,7 @@ impl CadCommand for HelixCommand { } Step::TopRadius => { let radius = Self::parse_value(value)?; - if radius >= 0.0 { + if radius >= 0.0 && (self.base_radius > 0.0 || radius > 0.0) { self.top_radius = radius; self.step = Step::Final; } @@ -408,7 +410,7 @@ impl CadCommand for HelixCommand { } Step::TopDiameter => { let diameter = Self::parse_value(value)?; - if diameter >= 0.0 { + if diameter >= 0.0 && (self.base_radius > 0.0 || diameter > 0.0) { self.top_radius = diameter * 0.5; self.step = Step::Final; } @@ -416,11 +418,15 @@ impl CadCommand for HelixCommand { } Step::Final => { let height = Self::parse_value(value)?; - (height.abs() > EPSILON).then(|| self.commit(height, self.plane.z)) + Some(self.commit(height, self.plane.z)) } Step::AxisEndpoint => None, Step::Turns => { let turns = Self::parse_turns(value)?; + if turns > 500.0 { + self.step = Step::Final; + return Some(CmdResult::NeedPoint); + } if turns > EPSILON { self.turns = turns; self.requested_turn_height = None; From 48a5d22efc3e1267c43d3646f589e5d3cfb4cb71 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:22:36 +0300 Subject: [PATCH 36/99] Match wipeout point stages and reject wide boundaries --- src/modules/draw/draw/wipeout.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index 5d4a93f87..d9b689958 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -116,13 +116,14 @@ impl CadCommand for WipeoutCommand { WipeoutMode::Draw if self.points.is_empty() => { t!("WIPEOUT Specify first point or [Frames/Polyline] :").into_owned() } + WipeoutMode::Draw if self.points.len() == 1 => { + t!("WIPEOUT Specify next point:").into_owned() + } + WipeoutMode::Draw if self.points.len() == 2 => { + t!("WIPEOUT Specify next point or [Undo]:").into_owned() + } WipeoutMode::Draw => { - let n = self.points.len(); - t!( - "WIPEOUT Specify next point or [Undo/Close] (%{n} points):", - n = n - ) - .into_owned() + t!("WIPEOUT Specify next point or [Close/Undo]:").into_owned() } WipeoutMode::Polyline => { t!("WIPEOUT Polyline Select a closed planar polyline:").into_owned() @@ -152,9 +153,13 @@ impl CadCommand for WipeoutCommand { CmdOption::new(t!("Polyline").as_ref(), "P"), ] } - WipeoutMode::Draw => vec![ + WipeoutMode::Draw if self.points.len() == 1 => Vec::new(), + WipeoutMode::Draw if self.points.len() == 2 => vec![ CmdOption::new(t!("Undo").as_ref(), "U"), + ], + WipeoutMode::Draw => vec![ CmdOption::new(t!("Close").as_ref(), "C"), + CmdOption::new(t!("Undo").as_ref(), "U"), ], WipeoutMode::Frames => vec![ CmdOption::new(t!("Off").as_ref(), "OFF"), @@ -215,6 +220,7 @@ impl CadCommand for WipeoutCommand { CmdResult::NeedPoint } WipeoutMode::Draw if self.points.len() >= 3 => self.finish_draw(), + WipeoutMode::Draw => CmdResult::NeedPoint, WipeoutMode::Frames => { CmdResult::Dispatch(format!("WIPEOUTFRAME {}", self.frame_mode)) } @@ -267,7 +273,7 @@ impl CadCommand for WipeoutCommand { self.mode = WipeoutMode::Polyline; Some(CmdResult::NeedPoint) } - "U" | "UNDO" if !self.points.is_empty() => Some(self.undo_point()), + "U" | "UNDO" if self.points.len() >= 2 => Some(self.undo_point()), "C" | "CLOSE" if self.points.len() >= 3 => Some(self.finish_draw()), _ => None, }, @@ -424,7 +430,9 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { match entity { EntityType::LwPolyline(polyline) => { - if polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0) { + if polyline.constant_width != 0.0 || polyline.vertices.iter().any(|vertex| { + vertex.bulge != 0.0 || vertex.start_width != 0.0 || vertex.end_width != 0.0 + }) { return None; } let raw: Vec<[f64; 2]> = polyline @@ -438,7 +446,10 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { from_ocs(&raw, polyline.normal, polyline.elevation) } EntityType::Polyline2D(polyline) => { - if polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0) { + if polyline.start_width != 0.0 || polyline.end_width != 0.0 + || polyline.vertices.iter().any(|vertex| { + vertex.bulge != 0.0 || vertex.start_width != 0.0 || vertex.end_width != 0.0 + }) { return None; } let raw: Vec<[f64; 2]> = polyline From f24fdc79653f40125ba329db6cbfb79b9dbb2ce6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:23:38 +0300 Subject: [PATCH 37/99] Implement nested object selection and placement for NCOPY --- src/app/command_driver.rs | 14 ++- src/app/commands/blocks.rs | 39 +------ src/command.rs | 2 + src/modules/draw/modify/mod.rs | 1 + src/modules/draw/modify/ncopy.rs | 169 +++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 src/modules/draw/modify/ncopy.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..341e98ca6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -929,6 +929,8 @@ impl OpenCADStudio { fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; + let preserve_commit_style = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.preserve_commit_style()); let preserve_commit_layer = self.tabs[i] .active_cmd .as_ref() @@ -1070,7 +1072,9 @@ impl OpenCADStudio { .all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); for entity in entities { - if preserve_commit_layer { + if preserve_commit_style { + let _ = self.commit_entity_handle_preserve_style(entity); + } else if preserve_commit_layer { let _ = self.commit_entity_handle_preserve_layer(entity); } else { self.commit_entity(entity); @@ -1093,7 +1097,13 @@ impl OpenCADStudio { .all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); for entity in entities { - self.commit_entity(entity); + if preserve_commit_style { + let _ = self.commit_entity_handle_preserve_style(entity); + } else if preserve_commit_layer { + let _ = self.commit_entity_handle_preserve_layer(entity); + } else { + self.commit_entity(entity); + } } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index d020e42cc..3b884049f 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -654,42 +654,13 @@ impl OpenCADStudio { } } - // NCOPY — copy the nested objects of the selected block reference(s) - // into model space, keeping the block (a non-destructive extraction; - // every nested object is copied — the block is not exploded). "NCOPY" | "NCOPYALL" => { - use crate::modules::draw::modify::explode::explode_entity; - let inserts: Vec = self.tabs[i] - .scene - .selected_entities() - .iter() - .filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_))) - .map(|(h, _)| *h) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - if inserts.is_empty() { - self.command_line - .push_error(crate::t!("NCOPY: select a block reference first.").as_ref()); - return Some(Task::none()); - } - self.push_undo_snapshot(i, "NCOPY"); - let mut n = 0usize; - for h in &inserts { - let nested = match self.tabs[i].scene.document.get_entity(*h).cloned() { - Some(ins) => explode_entity(&ins, &self.tabs[i].scene.document), - None => Vec::new(), - }; - for e in nested { - self.tabs[i].scene.add_entity(e); - n += 1; - } - } - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "NCOPY: copied {n} nested object(s) into model space (blocks kept)." - ).as_ref()); + let command = crate::modules::draw::modify::ncopy::NcopyCommand::new( + &self.tabs[i].scene.document, + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } - _ => return None, } Some(self.finish_dispatch(cmd)) diff --git a/src/command.rs b/src/command.rs index d69859462..5e3098f3a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1940,6 +1940,8 @@ impl InputKind { } pub trait CadCommand: Send { + /// Preserve source appearance for commands that extract existing entities. + fn preserve_commit_style(&self) -> bool { false } /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. fn preserve_commit_layer(&self) -> bool { diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index a497c4c4e..4964db414 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -13,6 +13,7 @@ pub mod join; pub mod lengthen; pub mod mirror; pub mod mledit; +pub mod ncopy; pub mod offset; pub mod pedit; pub mod block_edit; diff --git a/src/modules/draw/modify/ncopy.rs b/src/modules/draw/modify/ncopy.rs new file mode 100644 index 000000000..4221a4e94 --- /dev/null +++ b/src/modules/draw/modify/ncopy.rs @@ -0,0 +1,169 @@ +use acadrust::{CadDocument, EntityType, Handle}; +use glam::DVec3; +use std::collections::{HashMap, HashSet}; +use crate::command::{CadCommand, CmdOption, CmdResult, EntityTransform}; +use crate::entities::traits::EntityTypeOps; +use crate::scene::convert::acad_to_render::RenderObject; + +enum Step { Select, Base, Target(DVec3), ArrayCount(DVec3), ArrayTarget(DVec3, usize, bool) } +pub struct NcopyCommand { + nested: HashMap>, + hit_paths: HashMap>>, + selected: Vec<(Handle, usize)>, + step: Step, + multiple: bool, + placements: usize, +} +impl NcopyCommand { + pub fn new(document: &CadDocument) -> Self { + fn leaves(entity: &EntityType, document: &CadDocument, path: &mut HashSet, output: &mut Vec) { + let EntityType::Insert(insert) = entity else { output.push(entity.clone()); return; }; + let name = insert.block_name.to_ascii_uppercase(); + if !path.insert(name.clone()) { return; } + for child in super::explode::explode_entity(entity, document) { + leaves(&child, document, path, output); + } + path.remove(&name); + } + let nested: HashMap<_, _> = document.entities().filter(|e| matches!(e, EntityType::Insert(_))) + .map(|e| { + let mut entities = Vec::new(); + leaves(e, document, &mut HashSet::new(), &mut entities); + (e.common().handle, entities) + }).collect(); + let hit_paths = nested.iter().map(|(&handle, entities)| { + let paths = entities.iter().map(|entity| { + if crate::entities::curve::entity_curve(entity).is_some() { return Vec::new(); } + match entity.to_render_entity(document).map(|render| render.object) { + Some(RenderObject::Dot(point)) => vec![point], + Some(RenderObject::Lines(points) | RenderObject::SegmentedLines(points) + | RenderObject::TaperedLines(points, _) | RenderObject::BoundaryLines { points, .. }) => points, + _ => Vec::new(), + } + }).collect(); + (handle, paths) + }).collect(); + Self { nested, hit_paths, selected: Vec::new(), step: Step::Select, multiple: false, placements: 0 } + } + fn place(&self, delta: DVec3) -> CmdResult { + if !delta.is_finite() { return CmdResult::NeedPoint; } + let copies = self.selected.iter().filter_map(|(h, index)| self.nested.get(h)?.get(*index)).cloned() + .map(|mut entity| { + crate::scene::view::dispatch::apply_transform(&mut entity, &EntityTransform::Translate(delta)); + entity.common_mut().handle = Handle::NULL; + entity + }).collect(); + if self.multiple { CmdResult::CommitEntities(copies) } + else { CmdResult::CommitEntitiesAndExit(copies) } + } +} +impl CadCommand for NcopyCommand { + fn name(&self) -> &'static str { "NCOPY" } + fn prompt(&self) -> String { + match self.step { + Step::Select => "Select nested objects:".into(), + Step::Base => "Specify base point or [Displacement/Multiple] :".into(), + Step::Target(_) => "Specify second point or [Array] :".into(), + Step::ArrayCount(_) => "Enter number of items to array:".into(), + Step::ArrayTarget(_, _, false) => "Specify second point or [Fit]:".into(), + Step::ArrayTarget(_, _, true) => "Specify last point:".into(), + } + } + fn options(&self) -> Vec { + match self.step { + Step::Base => vec![CmdOption::new("Displacement", "D"), CmdOption::new("Multiple", "M")], + Step::Target(_) if self.multiple && self.placements > 0 => vec![CmdOption::new("Array", "A"), CmdOption::new("Exit", "E"), CmdOption::new("Undo", "U")], + Step::Target(_) => vec![CmdOption::new("Array", "A")], + Step::ArrayTarget(_, _, false) => vec![CmdOption::new("Fit", "F")], + _ => Vec::new(), + } + } + fn preserve_commit_style(&self) -> bool { true } + fn preserve_commit_layer(&self) -> bool { true } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::Select) } + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if let Some(entities) = self.nested.get(&handle) { + let nearest = entities.iter().enumerate().filter_map(|(index, entity)| { + let distance = if let Some(curve) = crate::entities::curve::entity_curve(entity) { + let projected = curve.plane.project([point.x, point.y, point.z])?; + cadkernel::geom2d::closest_point(&curve.curve, projected).distance + .hypot(curve.plane.distance_to(point.to_array())?) + } else { + let path = self.hit_paths.get(&handle)?.get(index)?; + let point = cadkernel::space::Vec3::from(point.to_array()); + if path.len() == 1 { + point.distance(cadkernel::space::Vec3::from(path[0])) + } else { + path.windows(2).filter(|segment| segment.iter().flatten().all(|v| v.is_finite())) + .map(|segment| point.distance_to_segment(segment[0].into(), segment[1].into())) + .min_by(f64::total_cmp)? + } + }; + Some((index, distance)) + }).min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((index, _)) = nearest { + if !self.selected.contains(&(handle, index)) { self.selected.push((handle, index)); } + } + } + CmdResult::NeedPoint + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Select => CmdResult::NeedPoint, + Step::Base => { self.step = Step::Target(point); CmdResult::NeedPoint } + Step::Target(base) => { + if !point.is_finite() { return CmdResult::NeedPoint; } + self.placements += 1; + self.place(point - base) + } + Step::ArrayCount(_) => CmdResult::NeedPoint, + Step::ArrayTarget(base, count, fit) => { + let delta = (point - base) / if fit { (count - 1) as f64 } else { 1.0 }; + if !delta.is_finite() { return CmdResult::NeedPoint; } + let mut copies = Vec::new(); + for index in 0..count { + if let CmdResult::CommitEntities(mut entities) | CmdResult::CommitEntitiesAndExit(mut entities) = self.place(delta * index as f64) { + copies.append(&mut entities); + } + } + CmdResult::CommitEntitiesAndExit(copies) + } + } + } + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Select if !self.selected.is_empty() => { self.step = Step::Base; CmdResult::NeedPoint } + Step::Base => { self.step = Step::Target(DVec3::ZERO); CmdResult::NeedPoint } + Step::Target(base) if !self.multiple => self.place(base), + _ => CmdResult::Cancel, + } + } + fn point_step_accepts_keywords(&self) -> bool { matches!(self.step, Step::Base | Step::Target(_) | Step::ArrayTarget(..)) } + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::Select) } + fn on_text_input(&mut self, text: &str) -> Option { + if let Step::ArrayCount(base) = self.step { + if let Ok(count) = text.trim().parse::() { + if (2..=32767).contains(&count) { self.step = Step::ArrayTarget(base, count, false); } + } + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "A" | "ARRAY" => { + if let Step::Target(base) = self.step { self.step = Step::ArrayCount(base); } + Some(CmdResult::NeedPoint) + } + "F" | "FIT" => { + if let Step::ArrayTarget(base, count, _) = self.step { self.step = Step::ArrayTarget(base, count, true); } + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" if self.multiple && self.placements > 0 => { + self.placements -= 1; + Some(CmdResult::UndoDocument) + } + "E" | "EXIT" if self.multiple => Some(CmdResult::Cancel), + "M" | "MULTIPLE" if matches!(self.step, Step::Base) => { self.multiple = true; Some(CmdResult::NeedPoint) } + "D" | "DISPLACEMENT" if matches!(self.step, Step::Base) => { self.step = Step::Target(DVec3::ZERO); Some(CmdResult::NeedPoint) } + _ => None, + } + } +} From cbc81df7cccb835cc3490b1060512da7db1c7f93 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:23:42 +0300 Subject: [PATCH 38/99] Match XLINE command behavior --- src/modules/draw/draw/ray.rs | 202 ++++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 28 deletions(-) diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 6318fde7f..d8a01d9d6 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -9,7 +9,7 @@ use acadrust::entities::{Ray as RayEnt, XLine as XLineEnt}; use acadrust::types::Vector3; use acadrust::EntityType; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; @@ -123,11 +123,62 @@ impl CadCommand for RayCommand { pub struct XLineCommand { base: Option, + mode: XLineMode, + plane: WorkingPlane, + reference: Option<(DVec3, DVec3)>, + picked: Option, + offset: Option, +} + +#[derive(Clone, Copy)] +enum XLineMode { + Points, + Direction(DVec3), + Angle, + AngleReference, + Bisect, + OffsetDistance, + OffsetPick, + OffsetSide, } impl XLineCommand { pub fn new() -> Self { - Self { base: None } + Self { + base: None, + mode: XLineMode::Points, + plane: WorkingPlane::default(), + reference: None, + picked: None, + offset: None, + } + } + + fn geometry(&self, pt: DVec3) -> Option<(DVec3, DVec3)> { + if !pt.is_finite() { return None; } + let (base, direction) = match self.mode { + XLineMode::Points => (self.base?, pt - self.base?), + XLineMode::Direction(dir) => (pt, dir), + XLineMode::Bisect => { + let base = self.base?; + let first = self.reference?.1; + let last = (pt - base).try_normalize()?; + (base, first + last) + } + XLineMode::OffsetSide => { + let (base, dir) = self.reference?; + if let Some(distance) = self.offset { + let normal = self.plane.z.cross(dir).try_normalize()?; + let side = (pt - base).dot(normal); + if side.abs() < 1e-10 { return None; } + (base + normal * distance * side.signum(), dir) + } else { + (pt, dir) + } + } + _ => return None, + }; + Some((base, direction.try_normalize()?)) } } @@ -137,43 +188,138 @@ impl CadCommand for XLineCommand { } fn prompt(&self) -> String { - if self.base.is_none() { - t!("XLINE Specify a point:").into_owned() - } else { - t!("XLINE Specify through point:").into_owned() - } + match self.mode { + XLineMode::Points if self.base.is_none() => "XLINE Specify a point or [Hor/Ver/Ang/Bisect/Offset]:", + XLineMode::Angle => "XLINE Enter angle of xline <0> or [Reference]:", + XLineMode::AngleReference | XLineMode::OffsetPick => "XLINE Select a line object:", + XLineMode::Bisect if self.base.is_none() => "XLINE Specify angle vertex point:", + XLineMode::Bisect if self.reference.is_none() => "XLINE Specify angle start point:", + XLineMode::Bisect => "XLINE Specify angle end point:", + XLineMode::OffsetDistance => "XLINE Specify offset distance or [Through] :", + XLineMode::OffsetSide if self.offset.is_some() => "XLINE Specify side to offset:", + _ => "XLINE Specify through point:", + }.into() } fn options(&self) -> Vec { use crate::command::CmdOption; - if self.base.is_some() { - vec![CmdOption::enter(t!("Done").as_ref())] - } else { - vec![] + match self.mode { + XLineMode::Points if self.base.is_none() => vec![ + CmdOption::new("Hor", "H"), CmdOption::new("Ver", "V"), + CmdOption::new("Ang", "A"), CmdOption::new("Bisect", "B"), + CmdOption::new("Offset", "O"), + ], + XLineMode::Angle => vec![CmdOption::new("Reference", "R")], + XLineMode::OffsetDistance => vec![CmdOption::new("Through", "T")], + _ => vec![CmdOption::enter(t!("Done").as_ref())], } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Some(base) = self.base { - let dir = pt - base; - let len = dir.length(); - if len < 1e-6 { - return CmdResult::NeedPoint; - } - let dir_n = dir / len; - let xline = XLineEnt::new( + if !pt.is_finite() { return CmdResult::NeedPoint; } + if let Some((base, dir)) = self.geometry(pt) { + let entity = XLineEnt::new( Vector3::new(base.x, base.y, base.z), - Vector3::new(dir_n.x, dir_n.y, dir_n.z), + Vector3::new(dir.x, dir.y, dir.z), ); - CmdResult::CommitEntity(EntityType::XLine(xline)) - } else { - self.base = Some(pt); - CmdResult::NeedPoint + if matches!(self.mode, XLineMode::OffsetSide) { + self.mode = XLineMode::OffsetPick; + self.reference = None; + } + return CmdResult::CommitEntity(EntityType::XLine(entity)); } + match self.mode { + XLineMode::Points | XLineMode::Bisect if self.base.is_none() => self.base = Some(pt), + XLineMode::Bisect if self.reference.is_none() => { + if let Some(dir) = (pt - self.base.unwrap()).try_normalize() { + self.reference = Some((self.base.unwrap(), dir)); + } + } + _ => {} + } + CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.mode { + XLineMode::Angle => self.on_text_input("0").unwrap_or(CmdResult::NeedPoint), + XLineMode::OffsetDistance => { + self.mode = XLineMode::OffsetPick; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn wants_text_input(&self) -> bool { true } + fn point_step_accepts_keywords(&self) -> bool { true } + fn dyn_field(&self) -> crate::command::DynField { + match self.mode { + XLineMode::Angle => crate::command::DynField::Angle, + XLineMode::OffsetDistance => crate::command::DynField::Distance, + _ => crate::command::DynField::Point, + } + } + fn dyn_commit_as_text(&self) -> bool { + matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance) + } + fn dyn_auto_sign_angle(&self) -> bool { false } + fn needs_entity_pick(&self) -> bool { + matches!(self.mode, XLineMode::AngleReference | XLineMode::OffsetPick) + } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + fn on_entity_pick(&mut self, _handle: acadrust::Handle, _pt: DVec3) -> CmdResult { + let xyz = |v: Vector3| DVec3::new(v.x, v.y, v.z); + let reference = match self.picked.take() { + Some(EntityType::Line(line)) => Some((xyz(line.start), xyz(line.end) - xyz(line.start))), + Some(EntityType::Ray(line)) => Some((xyz(line.base_point), xyz(line.direction))), + Some(EntityType::XLine(line)) => Some((xyz(line.base_point), xyz(line.direction))), + _ => None, + }; + if let Some((base, dir)) = reference { + if let Some(dir) = dir.try_normalize() { + self.reference = Some((base, dir)); + self.mode = if matches!(self.mode, XLineMode::AngleReference) { + XLineMode::Angle + } else { XLineMode::OffsetSide }; + } + } + CmdResult::NeedPoint + } + fn on_text_input(&mut self, text: &str) -> Option { + let key = text.trim().to_ascii_uppercase(); + match self.mode { + XLineMode::Points if self.base.is_none() => { + self.mode = match key.as_str() { + "H" | "HOR" => XLineMode::Direction(self.plane.x), + "V" | "VER" => XLineMode::Direction(self.plane.y), + "A" | "ANG" => XLineMode::Angle, + "B" | "BISECT" => XLineMode::Bisect, + "O" | "OFFSET" => XLineMode::OffsetDistance, + _ => return None, + }; + } + XLineMode::Angle => { + if key == "R" || key == "REFERENCE" { + self.mode = XLineMode::AngleReference; + } else { + let angle: f64 = key.parse().ok().filter(|v: &f64| v.is_finite())?; + let (sin, cos) = angle.to_radians().sin_cos(); + let axis = self.reference.map_or(self.plane.x, |(_, dir)| dir); + self.mode = XLineMode::Direction(axis * cos + self.plane.z.cross(axis) * sin); + } + } + XLineMode::OffsetDistance => { + self.offset = if key == "T" || key == "THROUGH" { None } else { + Some(key.parse::().ok().filter(|v| v.is_finite() && *v > 0.0)?) + }; + self.mode = XLineMode::OffsetPick; + } + _ => return None, + } + Some(CmdResult::NeedPoint) } fn on_escape(&mut self) -> CmdResult { @@ -181,9 +327,9 @@ impl CadCommand for XLineCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); - let base = self.base?.as_vec3(); - let dir = (pt - base).normalize_or_zero(); + let (base, dir) = self.geometry(pt)?; + let base = base.as_vec3(); + let dir = dir.as_vec3(); let far_pos = base + dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT; Some(WireModel { From 452e61121b4c7e16038da9ba026c6edab3ef99e1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:23:42 +0300 Subject: [PATCH 39/99] Match RAY command behavior --- src/modules/draw/draw/ray.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 6318fde7f..6d010f80e 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -50,6 +50,9 @@ impl CadCommand for RayCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if !pt.is_finite() { + return CmdResult::NeedPoint; + } if let Some(base) = self.base { let dir = pt - base; let len = dir.length(); From 227928257b77ea47df2686bafdfc25536d5630fc Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:23:43 +0300 Subject: [PATCH 40/99] Match ALIGN command behavior --- src/modules/draw/modify/align.rs | 103 +++++++++++++++++-------------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index 8cd4109c3..47b5f4bdc 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -1,4 +1,4 @@ -// ALIGN command — align selected objects using 1 or 2 point pairs. +// ALIGN command — rigid 3D placement using one, two, or three point pairs. // // Workflow: // 1. Select objects (Enter to finish selection) @@ -23,6 +23,8 @@ pub struct AlignCommand { dst1: Option, src2: Option, dst2: Option, + src3: Option, + dst3: Option, } #[derive(PartialEq)] @@ -32,6 +34,8 @@ enum AlignState { Dst1, Src2, Dst2, + Src3, + Dst3, AskScale, } @@ -50,6 +54,8 @@ impl AlignCommand { dst1: None, src2: None, dst2: None, + src3: None, + dst3: None, } } } @@ -72,9 +78,11 @@ impl CadCommand for AlignCommand { t!("ALIGN Specify 2nd source point (Enter = translate only):").into_owned() } AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(), + AlignState::Src3 => "ALIGN Specify 3rd source point or :".into(), + AlignState::Dst3 => "ALIGN Specify 3rd destination point:".into(), AlignState::AskScale => { t!( - "ALIGN Scale objects based on alignment points? [Yes / No]:" + "ALIGN Scale objects based on alignment points? [Yes / No] :" ) .into_owned() } @@ -103,6 +111,7 @@ impl CadCommand for AlignCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if !pt.is_finite() { return CmdResult::NeedPoint; } match self.state { AlignState::Gathering => CmdResult::NeedPoint, AlignState::Src1 => { @@ -116,15 +125,36 @@ impl CadCommand for AlignCommand { CmdResult::NeedPoint } AlignState::Src2 => { + let pair = [self.src1.unwrap().to_array(), pt.to_array()]; + if cadkernel::space::align_point_pairs(&pair, &pair, false).is_none() { + return CmdResult::NeedPoint; + } self.src2 = Some(pt); self.state = AlignState::Dst2; CmdResult::NeedPoint } AlignState::Dst2 => { + let pair = [self.dst1.unwrap().to_array(), pt.to_array()]; + if cadkernel::space::align_point_pairs(&pair, &pair, false).is_none() { + return CmdResult::NeedPoint; + } self.dst2 = Some(pt); - self.state = AlignState::AskScale; + self.state = AlignState::Src3; CmdResult::NeedPoint } + AlignState::Src3 => { + let frame = [self.src1.unwrap().to_array(), self.src2.unwrap().to_array(), pt.to_array()]; + if cadkernel::space::align_point_pairs(&frame, &frame, false).is_none() { + return CmdResult::NeedPoint; + } + self.src3 = Some(pt); + self.state = AlignState::Dst3; + CmdResult::NeedPoint + } + AlignState::Dst3 => { + self.dst3 = Some(pt); + self.compute_align(false) + } AlignState::AskScale => CmdResult::NeedPoint, } } @@ -156,6 +186,10 @@ impl CadCommand for AlignCommand { } // Default option shown as . + AlignState::Src3 => { + self.state = AlignState::AskScale; + CmdResult::NeedPoint + } AlignState::AskScale => self.compute_align(false), _ => CmdResult::Cancel, @@ -204,6 +238,8 @@ impl CadCommand for AlignCommand { match self.state { AlignState::Src2 | AlignState::Dst2 + | AlignState::Src3 + | AlignState::Dst3 | AlignState::AskScale => { out.push(line(src1, dst1, "align_pair_1")); } @@ -230,10 +266,15 @@ impl CadCommand for AlignCommand { // Once both pairs are complete, keep both visible while // waiting for the Scale / No Scale decision. - AlignState::AskScale => { + AlignState::Src3 | AlignState::Dst3 | AlignState::AskScale => { if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) { out.push(line(src2, dst2, "align_pair_2")); } + if self.state == AlignState::Dst3 { + if let Some(src3) = self.src3 { + out.push(line(src3, pt, "align_pair_3_preview")); + } + } } _ => {} @@ -245,50 +286,20 @@ impl CadCommand for AlignCommand { impl AlignCommand { fn compute_align(&self, with_scale: bool) -> CmdResult { - let (s1, d1, s2, d2) = match (self.src1, self.dst1, self.src2, self.dst2) { - (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), - _ => return CmdResult::Cancel, - }; - - // Build transform: move s1→d1, rotate so s2-s1 aligns with d2-d1 (in the world XY plane) - let src_vec = s2 - s1; - let dst_vec = d2 - d1; - - let src_len = src_vec.length(); - let dst_len = dst_vec.length(); - - if src_len < 1e-6 || dst_len < 1e-6 { - // Degenerate: just translate - let delta = d1 - s1; - return CmdResult::TransformSelected( - self.handles.clone(), - EntityTransform::Translate(delta), - ); - } - - // Angle from src_vec to dst_vec in the world XY plane - let src_angle = src_vec.y.atan2(src_vec.x); - let dst_angle = dst_vec.y.atan2(dst_vec.x); - let angle = dst_angle - src_angle; - - let scale_factor = if with_scale { dst_len / src_len } else { 1.0 }; - - // Apply: translate to origin (s1), scale, rotate, translate to d1 - // We use the EntityTransform enum — it doesn't support composed transforms directly. - // Return a special align result that carries the full matrix. - let _ = (angle, scale_factor, with_scale); - - // Compose via AlignTransform CmdResult - CmdResult::AlignSelected { - handles: self.handles.clone(), - src1: s1, - dst1: d1, - angle_rad: angle, - scale: scale_factor, + let (Some(s1), Some(d1), Some(s2), Some(d2)) = (self.src1, self.dst1, self.src2, self.dst2) + else { return CmdResult::NeedPoint; }; + let mut source = vec![s1.to_array(), s2.to_array()]; + let mut target = vec![d1.to_array(), d2.to_array()]; + if let (Some(s3), Some(d3)) = (self.src3, self.dst3) { + source.push(s3.to_array()); + target.push(d3.to_array()); } + let Some(matrix) = cadkernel::space::align_point_pairs(&source, &target, with_scale) + else { return CmdResult::NeedPoint; }; + CmdResult::TransformSelected(self.handles.clone(), EntityTransform::Affine( + acadrust::types::Transform::from_matrix(acadrust::types::Matrix4 { m: matrix }), + )) } } - -// ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand From d10b50778ccf5a7ad479acac29cc235341ac6653 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:23:43 +0300 Subject: [PATCH 41/99] Match LENGTHEN command behavior --- src/modules/draw/modify/lengthen.rs | 362 +++++++++++++++------------- 1 file changed, 190 insertions(+), 172 deletions(-) diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 6c241cf64..a7f238481 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -1,6 +1,6 @@ // LENGTHEN command — extend or trim a Line or Arc by a specified delta or total. // -// Options (entered as text after the entity pick): +// Choose an option, enter its value, then pick objects repeatedly: // DE — extend by delta (positive extends, negative trims) // TO — set total length (Line) or arc length (Arc) // P — change by percentage (100 = no change, 150 = +50%) @@ -9,7 +9,7 @@ use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs}; use acadrust::entities::{ - Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt, + Ellipse as EllipseEnt, LwPolyline, Spline as SplineEnt, }; use cadkernel::geom2d::{ Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, @@ -25,120 +25,191 @@ const TAU: f64 = std::f64::consts::TAU; pub struct LengthenCommand { state: LenState, + picked: Option, + measurement: Option, + edits: usize, } +#[derive(Clone, Copy)] +enum ValueMode { Delta, Total, Percent, DeltaAngle, TotalAngle, Dynamic } + +struct LengthenDefaults { mode: ValueMode, delta: f64, total: f64, percent: f64, delta_angle: f64, total_angle: f64 } +static LENGTHEN_DEFAULTS: std::sync::Mutex = std::sync::Mutex::new( + LengthenDefaults { mode: ValueMode::Total, delta: 0.0, total: 1.0, percent: 100.0, delta_angle: 0.0, total_angle: 180.0 / std::f64::consts::PI } +); + enum LenState { - PickEntity, - PickOption { handle: Handle, pick_pt: Vec3 }, + ChooseMode, + Value(ValueMode), + Apply(LenMode), + DynamicPick, + DynamicPoint { handle: Handle, entity: EntityType, pick: DVec3 }, } impl LengthenCommand { pub fn new() -> Self { - Self { - state: LenState::PickEntity, - } + Self { state: LenState::ChooseMode, picked: None, measurement: None, edits: 0 } } } impl CadCommand for LengthenCommand { - fn name(&self) -> &'static str { - "LENGTHEN" - } + fn name(&self) -> &'static str { "LENGTHEN" } fn prompt(&self) -> String { match &self.state { - LenState::PickEntity => t!("LENGTHEN Select object:").into_owned(), - LenState::PickOption { .. } => { - t!("LENGTHEN Enter option [DE / TO / P ]:").into_owned() + LenState::ChooseMode => { + let prompt = t!("LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]:"); + self.measurement.map_or_else(|| prompt.to_string(), |length| format!("Length: {length:.4} {prompt}")) } + LenState::Value(ValueMode::Delta) => format!("LENGTHEN Enter delta length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta), + LenState::Value(ValueMode::Total) => format!("LENGTHEN Enter total length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total), + LenState::Value(ValueMode::Percent) => format!("LENGTHEN Enter percentage length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().percent), + LenState::Value(ValueMode::DeltaAngle) => format!("LENGTHEN Enter delta angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta_angle), + LenState::Value(ValueMode::TotalAngle) => format!("LENGTHEN Enter total angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total_angle), + LenState::DynamicPoint { .. } => "LENGTHEN Specify new end point:".into(), + LenState::Apply(_) | LenState::DynamicPick | LenState::Value(ValueMode::Dynamic) => t!("LENGTHEN Select an object to change or [Undo]:").into_owned(), } } - fn needs_entity_pick(&self) -> bool { - matches!(self.state, LenState::PickEntity) - } - - fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { let pt = pt.as_vec3(); - if handle.is_null() { - return CmdResult::NeedPoint; + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.state { + LenState::ChooseMode => vec![CmdOption::new("Delta", "DE"), CmdOption::new("Percent", "P"), CmdOption::new("Total", "TO"), CmdOption::new("Dynamic", "DY")], + LenState::Value(ValueMode::Delta | ValueMode::Total) => vec![CmdOption::new("Angle", "A")], + LenState::Apply(_) | LenState::DynamicPick => vec![CmdOption::new("Undo", "U")], + _ => Vec::new(), } - self.state = LenState::PickOption { - handle, - pick_pt: pt, - }; - CmdResult::NeedPoint } - fn wants_text_input(&self) -> bool { - matches!(self.state, LenState::PickOption { .. }) + fn needs_entity_pick(&self) -> bool { matches!(self.state, LenState::ChooseMode | LenState::Apply(_) | LenState::DynamicPick) } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if handle.is_null() { return CmdResult::NeedPoint; } + let Some(entity) = self.picked.take() else { return CmdResult::NeedPoint; }; + match &self.state { + LenState::ChooseMode => { + self.measurement = crate::entities::curve::entity_curve(&entity) + .map(|curve| curve.curve.length()).filter(|length| length.is_finite()); + CmdResult::NeedPoint + } + LenState::Apply(mode) => match lengthen_entity_precise(&entity, pt, mode) { + Some(replacement) => CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]), + None => CmdResult::NeedPoint, + }, + LenState::DynamicPick if matches!(entity, EntityType::Line(_) | EntityType::Arc(_)) => { + self.state = LenState::DynamicPoint { handle, entity, pick: pt }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } } + fn on_entity_replaced(&mut self, _old: Handle, _new_handles: &[Handle]) { self.edits += 1; } + fn wants_text_input(&self) -> bool { true } + fn dyn_commit_as_text(&self) -> bool { matches!(self.state, LenState::Value(_)) } + fn dyn_auto_sign_angle(&self) -> bool { false } fn dyn_field(&self) -> crate::command::DynField { - if matches!(self.state, LenState::PickOption { .. }) { - crate::command::DynField::Scalar - } else { - crate::command::DynField::Point - } + if matches!(self.state, LenState::Value(ValueMode::DeltaAngle | ValueMode::TotalAngle)) { crate::command::DynField::Angle } + else if matches!(self.state, LenState::Value(_)) { crate::command::DynField::Scalar } + else { crate::command::DynField::Point } } fn on_text_input(&mut self, text: &str) -> Option { - let (handle, pick_pt) = match &self.state { - LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt), - _ => return None, - }; - let pick_pt = pick_pt.as_dvec3(); - - let text = text.trim().to_uppercase(); - if let Some(rest) = text.strip_prefix("DE ").or_else(|| text.strip_prefix("DE")) { - let delta: f64 = rest.trim().replace(',', ".").parse().ok()?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Delta(delta), - }) - } else if let Some(rest) = text.strip_prefix("TO ").or_else(|| text.strip_prefix("TO")) { - let total: f64 = rest - .trim() - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v > 0.0)?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Total(total), - }) - } else if let Some(rest) = text.strip_prefix("P ").or_else(|| text.strip_prefix("P")) { - let pct: f64 = rest - .trim() - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v > 0.0)?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Percent(pct), - }) - } else { - // Try plain number as delta - let delta: f64 = text.replace(',', ".").parse().ok()?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Delta(delta), - }) + if matches!(self.state, LenState::DynamicPoint { .. }) { return None; } + let upper = text.trim().to_uppercase(); + if upper.is_empty() { return Some(self.on_enter()); } + if matches!(self.state, LenState::Apply(_) | LenState::DynamicPick) { + return Some(if matches!(upper.as_str(), "U" | "UNDO") && self.edits > 0 { + self.edits -= 1; + CmdResult::UndoDocument + } else { CmdResult::NeedPoint }); } + if matches!(self.state, LenState::ChooseMode) { + let mut parts = upper.split_whitespace(); + let mode = match parts.next().unwrap_or("") { + "D" | "DE" | "DELTA" => ValueMode::Delta, + "T" | "TO" | "TOTAL" => ValueMode::Total, + "P" | "PERCENT" => ValueMode::Percent, + "DY" | "DYNAMIC" => ValueMode::Dynamic, + _ => return Some(CmdResult::NeedPoint), + }; + LENGTHEN_DEFAULTS.lock().unwrap().mode = mode; + self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) }; + if let Some(value) = parts.next() { return self.on_text_input(value); } + return Some(CmdResult::NeedPoint); + } + let LenState::Value(mode) = self.state else { return Some(CmdResult::NeedPoint); }; + if matches!(upper.as_str(), "A" | "ANGLE") { + self.state = match mode { + ValueMode::Delta => LenState::Value(ValueMode::DeltaAngle), + ValueMode::Total => LenState::Value(ValueMode::TotalAngle), + _ => return Some(CmdResult::NeedPoint), + }; + return Some(CmdResult::NeedPoint); + } + let Some(value) = upper.replace(',', ".").parse::().ok().filter(|v| v.is_finite()) else { + return Some(CmdResult::NeedPoint); + }; + if !matches!(mode, ValueMode::Delta | ValueMode::DeltaAngle) && value <= 0.0 { return Some(CmdResult::NeedPoint); } + if matches!(mode, ValueMode::TotalAngle) && value >= 360.0 { return Some(CmdResult::NeedPoint); } + { + let mut defaults = LENGTHEN_DEFAULTS.lock().unwrap(); + match mode { ValueMode::Delta => defaults.delta = value, ValueMode::Total => defaults.total = value, ValueMode::Percent => defaults.percent = value, + ValueMode::DeltaAngle => defaults.delta_angle = value, ValueMode::TotalAngle => defaults.total_angle = value, ValueMode::Dynamic => {} } + } + self.state = LenState::Apply(match mode { + ValueMode::Delta => LenMode::Delta(value), + ValueMode::Total => LenMode::Total(value), + ValueMode::Percent => LenMode::Percent(value), + ValueMode::DeltaAngle => LenMode::DeltaAngle(value.to_radians()), + ValueMode::TotalAngle => LenMode::TotalAngle(value.to_radians()), + ValueMode::Dynamic => return Some(CmdResult::NeedPoint), + }); + Some(CmdResult::NeedPoint) } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if let LenState::DynamicPoint { handle, entity, pick } = &self.state { + if let Some(replacement) = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt)) { + let handle = *handle; + self.state = LenState::DynamicPick; + return CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]); + } + } CmdResult::NeedPoint } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let LenState::DynamicPoint { entity, pick, .. } = &self.state else { return None; }; + let replacement = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt))?; + let curve = crate::entities::curve::entity_curve(&replacement)?; + let points = crate::entities::curve::curve_points(&curve).into_iter() + .map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + Some(crate::scene::model::wire_model::WireModel::solid( + "lengthen_dynamic_preview".into(), points, + crate::scene::model::wire_model::WireModel::CYAN, false, + )) + } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.state { + LenState::ChooseMode => { + let mode = LENGTHEN_DEFAULTS.lock().unwrap().mode; + self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) }; + CmdResult::NeedPoint + } + LenState::Value(mode) => { + let value = { + let defaults = LENGTHEN_DEFAULTS.lock().unwrap(); + match mode { ValueMode::Delta => defaults.delta, ValueMode::Total => defaults.total, ValueMode::Percent => defaults.percent, + ValueMode::DeltaAngle => defaults.delta_angle, ValueMode::TotalAngle => defaults.total_angle, ValueMode::Dynamic => 0.0 } + }; + self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint) + } + _ => CmdResult::Cancel, + } } } - // ── Mode enum (also used in CmdResult) ──────────────────────────────────── #[derive(Clone)] @@ -146,6 +217,9 @@ pub enum LenMode { Delta(f64), Total(f64), Percent(f64), + DeltaAngle(f64), + TotalAngle(f64), + Dynamic(DVec3), } // ── Geometry ─────────────────────────────────────────────────────────────── @@ -153,96 +227,48 @@ pub enum LenMode { /// Apply LENGTHEN to a Line, Arc, Ellipse, or Spline. /// `pick_pt` determines which end to extend/trim (closest end is modified). pub fn lengthen_entity(entity: &EntityType, pick_pt: Vec3, mode: &LenMode) -> Option { + lengthen_entity_precise(entity, pick_pt.as_dvec3(), mode) +} + +fn lengthen_entity_precise(entity: &EntityType, pick: DVec3, mode: &LenMode) -> Option { + use cadkernel::space::lengthen::{LengthChange, lengthen_line, lengthen_arc}; + let change = match mode { + LenMode::Delta(value) => LengthChange::Delta(*value), + LenMode::Total(value) => LengthChange::Total(*value), + LenMode::Percent(value) => LengthChange::Percent(*value), + LenMode::DeltaAngle(value) => LengthChange::DeltaAngle(*value), + LenMode::TotalAngle(value) => LengthChange::TotalAngle(*value), + LenMode::Dynamic(point) => LengthChange::Dynamic(point.to_array()), + }; match entity { - EntityType::Line(l) => lengthen_line(l, pick_pt, mode), - EntityType::Arc(a) => lengthen_arc(a, pick_pt, mode), - EntityType::Ellipse(e) => lengthen_ellipse(e, pick_pt, mode), - EntityType::Spline(s) => lengthen_spline(s, pick_pt, mode), + EntityType::Line(line) => { + let [start, end] = lengthen_line( + [line.start.x, line.start.y, line.start.z], + [line.end.x, line.end.y, line.end.z], pick.to_array(), change)?; + let mut result = line.clone(); + result.common.handle = Handle::NULL; + result.start = Vector3::new(start[0], start[1], start[2]); + result.end = Vector3::new(end[0], end[1], end[2]); + Some(EntityType::Line(result)) + } + EntityType::Arc(arc) => { + let (start, end) = lengthen_arc(&crate::entities::curve::arc_curve(arc), pick.to_array(), change)?; + let mut result = arc.clone(); + result.common.handle = Handle::NULL; + result.start_angle = start; + result.end_angle = end; + Some(EntityType::Arc(result)) + } + EntityType::Ellipse(e) => lengthen_ellipse(e, pick.as_vec3(), mode), + EntityType::Spline(s) => lengthen_spline(s, pick.as_vec3(), mode), EntityType::LwPolyline(p) => { let p = crate::entities::curve::lwpolyline_world_xy(p)?; - lengthen_lwpoly(&p, pick_pt, mode) + lengthen_lwpoly(&p, pick.as_vec3(), mode) } _ => None, } } -fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option { - // Keep the line's own coordinates on the f64 grid; only pick_pt (screen-only, - // nearest-end selection) widens for the distance compare. - let sx = line.start.x; - let sy = line.start.y; - let ex = line.end.x; - let ey = line.end.y; - - let dx = ex - sx; - let dy = ey - sy; - let current_len = (dx * dx + dy * dy).sqrt(); - if current_len < 1e-10 { - return None; - } - - let new_len = apply_mode(current_len, mode)?; - if new_len < 1e-10 { - return None; - } - - let ux = dx / current_len; - let uy = dy / current_len; - - // Which end is closer to pick? - let px = pick_pt.x as f64; - let py = pick_pt.y as f64; - let dist_to_start = (px - sx).hypot(py - sy); - let dist_to_end = (px - ex).hypot(py - ey); - - let mut result = line.clone(); - result.common.handle = Handle::NULL; - - if dist_to_end <= dist_to_start { - // Extend/trim the end - let new_x = sx + ux * new_len; - let new_y = sy + uy * new_len; - result.end = Vector3::new(new_x, new_y, line.end.z); - } else { - // Extend/trim the start (move start backward along dir) - let new_x = ex - ux * new_len; - let new_y = ey - uy * new_len; - result.start = Vector3::new(new_x, new_y, line.start.z); - } - Some(EntityType::Line(result)) -} - -fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option { - // Current arc span - let span = arc_span_rad(arc.start_angle, arc.end_angle); - let current_arc_len = arc.radius * span; - - let new_arc_len = apply_mode(current_arc_len, mode)?; - if new_arc_len < 1e-10 || new_arc_len >= std::f64::consts::TAU * arc.radius - 1.0e-9 { - return None; - } - let new_span = new_arc_len / arc.radius; - - // Which end (start or end angle) is closer to pick? - let curve = crate::entities::curve::arc_curve(arc); - let start_pt = Vec3::from_array(curve.point_at(0.0).map(|value| value as f32)); - let end_pt = Vec3::from_array(curve.point_at(1.0).map(|value| value as f32)); - let dist_start = (pick_pt - start_pt).length(); - let dist_end = (pick_pt - end_pt).length(); - - let mut result = arc.clone(); - result.common.handle = Handle::NULL; - - if dist_end <= dist_start { - // Extend end angle - result.end_angle = arc.start_angle + new_span; - } else { - // Extend start angle (move start backwards) - result.start_angle = arc.end_angle - new_span; - } - Some(EntityType::Arc(result)) -} - fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option { let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { @@ -318,15 +344,7 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option { LenMode::Delta(d) => Some(current + d), LenMode::Total(t) => Some(*t), LenMode::Percent(p) => Some(current * p / 100.0), - } -} - -fn arc_span_rad(start: f64, end: f64) -> f64 { - let span = (end - start).rem_euclid(TAU); - if span < 1e-6 { - TAU - } else { - span + _ => None, } } From 88956e306b239ba746eec6450573b5c2b1aee638 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:24:15 +0300 Subject: [PATCH 42/99] Apply SETBYLAYER choices and property mask to selected objects --- src/app/commands/styleprops.rs | 72 +++++++++++++++++++-- src/modules/draw/modify/mod.rs | 1 + src/modules/draw/modify/setbylayer.rs | 90 +++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/modules/draw/modify/setbylayer.rs diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index b99da19b4..0ac9061ae 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -645,14 +645,56 @@ impl OpenCADStudio { // ── SETBYLAYER — clear color/linetype/lineweight overrides ──── // Resets the selected entities' direct property overrides back to // ByLayer so they follow their layer again. + "SETBYLAYERMODE" => { + let command = crate::modules::draw::modify::setbylayer::ModeCommand; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + value if value.starts_with("SETBYLAYERMODE ") => { + if let Ok(mode) = value.trim_start_matches("SETBYLAYERMODE ").trim().parse::() { + crate::modules::draw::modify::setbylayer::set_mode(mode); + } else { self.command_line.push_error("SETBYLAYERMODE requires an integer from 0 to 255."); } + } "SETBYLAYER" => { - let handles: Vec<_> = self.tabs[i] + let command = crate::modules::draw::modify::setbylayer::SetByLayerCommand::new( + self.tabs[i].scene.selected.iter().copied().collect(), + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + cmd if cmd.starts_with("SETBYLAYER_APPLY ") => { + let flags: Vec<_> = cmd.split_whitespace().skip(1).collect(); + if flags.len() != 2 || flags.iter().any(|flag| !matches!(*flag, "0" | "1")) { + return Some(Task::none()); + } + let mask = crate::modules::draw::modify::setbylayer::mode(); + let change_byblock = flags[0] == "1"; + let include_blocks = flags[1] == "1"; + let mut handles: Vec<_> = self.tabs[i] .scene .selected_entities() .into_iter() .map(|(h, _)| h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); + if include_blocks { + let mut visited: std::collections::HashSet<_> = handles.iter().copied().collect(); + let mut index = 0; + while index < handles.len() { + let children = match self.tabs[i].scene.document.get_entity(handles[index]) { + Some(acadrust::EntityType::Insert(insert)) => self.tabs[i].scene.document + .block_records.get(&insert.block_name) + .map(|block| block.entity_handles.clone()).unwrap_or_default(), + _ => Vec::new(), + }; + for child in children { + if visited.insert(child) && !self.tabs[i].scene.is_layer_locked(child) { + handles.push(child); + } + } + index += 1; + } + } if handles.is_empty() { self.command_line .push_error(crate::t!("SETBYLAYER: select entities first.").as_ref()); @@ -662,11 +704,29 @@ impl OpenCADStudio { for handle in &handles { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) { let common = entity.common_mut(); - common.color = acadrust::types::Color::ByLayer; - common.color_name = None; - common.color_book_handle = None; - common.linetype = "ByLayer".to_string(); - common.line_weight = acadrust::types::LineWeight::ByLayer; + if mask & 1 != 0 && (change_byblock || common.color != acadrust::types::Color::ByBlock) { + common.color = acadrust::types::Color::ByLayer; + common.color_name = None; + common.color_book_handle = None; + } + if mask & 2 != 0 && (change_byblock || !common.linetype.eq_ignore_ascii_case("ByBlock")) { + common.linetype = "ByLayer".to_string(); + common.linetype_handle = None; + } + if mask & 4 != 0 && (change_byblock || common.line_weight != acadrust::types::LineWeight::ByBlock) { + common.line_weight = acadrust::types::LineWeight::ByLayer; + } + if mask & 8 != 0 && (change_byblock || common.material_flags != 1) { + common.material_flags = 0; + common.material_handle = None; + } + if mask & 16 != 0 && (change_byblock || common.plotstyle_flags != 1) { + common.plotstyle_flags = 0; + common.plotstyle_handle = None; + } + if mask & 128 != 0 && (change_byblock || common.transparency != acadrust::types::Transparency::BY_BLOCK) { + common.transparency = acadrust::types::Transparency::BY_LAYER; + } changed += 1; } } diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index a497c4c4e..657807fd4 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -20,6 +20,7 @@ pub mod refedit; pub mod reverse; pub mod rotate; pub mod scale; +pub mod setbylayer; pub mod spline_ops; pub mod splinedit; pub mod stretch; diff --git a/src/modules/draw/modify/setbylayer.rs b/src/modules/draw/modify/setbylayer.rs new file mode 100644 index 000000000..6380a9c0b --- /dev/null +++ b/src/modules/draw/modify/setbylayer.rs @@ -0,0 +1,90 @@ +use acadrust::Handle; +use glam::DVec3; +use crate::command::{CadCommand, CmdOption, CmdResult}; + +static MODE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(255); +pub fn mode() -> u8 { MODE.load(std::sync::atomic::Ordering::Relaxed) } +pub fn set_mode(value: u8) { MODE.store(value, std::sync::atomic::Ordering::Relaxed); } +enum Step { Selection, ByBlock, Blocks } + +pub struct SetByLayerCommand { + selected: Vec, + step: Step, + change_byblock: bool, + include_blocks: bool, +} + +impl SetByLayerCommand { + pub fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { Step::Selection } else { Step::ByBlock }; + Self { selected, step, change_byblock: mode() & 32 != 0, include_blocks: mode() & 64 != 0 } + } + + fn answer(&mut self, yes: bool) -> CmdResult { + match self.step { + Step::ByBlock => { + self.change_byblock = yes; + self.step = Step::Blocks; + CmdResult::NeedPoint + } + Step::Blocks => { + let flags = (mode() & !(32 | 64)) | if self.change_byblock { 32 } else { 0 } | if yes { 64 } else { 0 }; + set_mode(flags); + CmdResult::Relaunch( + format!("SETBYLAYER_APPLY {} {}", u8::from(self.change_byblock), u8::from(yes)), + self.selected.clone(), + ) }, + Step::Selection => CmdResult::NeedPoint, + } + } +} + +impl CadCommand for SetByLayerCommand { + fn name(&self) -> &'static str { "SETBYLAYER" } + fn prompt(&self) -> String { + match self.step { + Step::Selection => "Select objects:".into(), + Step::ByBlock => format!("Change ByBlock to ByLayer? [Yes/No] <{}>:", if self.change_byblock { "Yes" } else { "No" }), + Step::Blocks => format!("Include blocks? [Yes/No] <{}>:", if self.include_blocks { "Yes" } else { "No" }), + } + } + fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Selection) } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected = handles; + CmdResult::NeedPoint + } + fn options(&self) -> Vec { + if self.is_selection_gathering() { Vec::new() } + else { vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] } + } + fn wants_text_input(&self) -> bool { !self.is_selection_gathering() } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + if self.is_selection_gathering() { + if self.selected.is_empty() { CmdResult::Cancel } + else { self.step = Step::ByBlock; CmdResult::NeedPoint } + } else { self.answer(if matches!(self.step, Step::ByBlock) { self.change_byblock } else { self.include_blocks }) } + } + fn on_text_input(&mut self, text: &str) -> Option { + Some(match text.trim().to_uppercase().as_str() { + "Y" | "YES" => self.answer(true), + "N" | "NO" => self.answer(false), + _ => CmdResult::NeedPoint, + }) + } +} + +pub struct ModeCommand; +impl CadCommand for ModeCommand { + fn name(&self) -> &'static str { "SETBYLAYERMODE" } + fn prompt(&self) -> String { format!("Enter new value for SETBYLAYERMODE <{}>:", mode()) } + fn wants_text_input(&self) -> bool { true } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_text_input(&mut self, text: &str) -> Option { + Some(if let Ok(value) = text.trim().parse::() { + set_mode(value); CmdResult::Cancel + } else { CmdResult::NeedPoint }) + } +} +inventory::submit!(crate::command::CommandRegistration { names: &["SETBYLAYERMODE"] }); From 60da28265417a6004eaa227bc91221b4d5a82080 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:24:39 +0300 Subject: [PATCH 43/99] Add tolerant OVERKILL settings and geometric cleanup --- src/app/commands/styleprops.rs | 125 +++++++++++++++----------- src/modules/draw/modify/mod.rs | 1 + src/modules/draw/modify/overkill.rs | 133 ++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 51 deletions(-) create mode 100644 src/modules/draw/modify/overkill.rs diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index b99da19b4..068138552 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -682,61 +682,84 @@ impl OpenCADStudio { } } - // ── OVERKILL — delete duplicate (identical) objects ────────── - // Removes objects that are identical in geometry AND properties to - // another object (compared with the handle ignored). Operates on - // the current selection, or the whole drawing when nothing is - // selected. Conservative: only exact duplicates are removed. - "OVERKILL" => { - use acadrust::Handle; - let selected: std::collections::HashSet = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h.value()) - .collect(); - // Capture (handle, type-name, handle-normalized clone) for each - // candidate while the document is borrowed immutably. - let candidates: Vec<(Handle, String, acadrust::EntityType)> = self.tabs[i] - .scene - .document - .entities() - .filter(|e| { - (selected.is_empty() || selected.contains(&e.common().handle.value())) - && !self.tabs[i].scene.is_layer_locked(e.common().handle) - }) - .map(|e| { - let key = crate::entities::names::dxf_name(e).to_string(); - let mut norm = e.clone(); - norm.common_mut().handle = Handle::NULL; - (e.common().handle, key, norm) - }) - .collect(); - // Bucket by (type, layer) so only like objects are compared. - let mut kept: Vec<(String, acadrust::EntityType)> = Vec::new(); - let mut dups: Vec = Vec::new(); - for (h, key, norm) in &candidates { - let bucket = format!("{key}\u{0}{}", norm.common().layer); - if kept.iter().any(|(b, e)| b == &bucket && e == norm) { - dups.push(*h); - } else { - kept.push((bucket, norm.clone())); + // OVERKILL gathers objects, exposes cleanup settings, then applies one undo group. + "OVERKILL" | "-OVERKILL" => { + let handles=self.tabs[i].scene.selected_entities().iter().map(|(h,_)|*h).collect(); + let command=crate::modules::draw::modify::overkill::OverkillCommand::new(handles); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd=Some(Box::new(command)); + } + value if value.starts_with("OVERKILL_APPLY ") => { + use crate::modules::draw::modify::overkill::{normalized,optimize}; + let values:Vec<_>=value.split_whitespace().skip(1).collect(); + if values.len()!=6 { return Some(self.finish_dispatch(cmd)); } + let tolerance=values[0].parse::().unwrap_or(1e-6); + let ignore=values[1].parse::().unwrap_or(0); + let optimize_plines=values[2]=="1"; + let overlap=values[3]=="1"; + let end_to_end=values[4]=="1"; + let preserve_associative=values[5]=="1"; + let mut candidates:Vec<_>=self.tabs[i].scene.selected_entities().into_iter() + .filter(|(h,e)|!self.tabs[i].scene.is_layer_locked(*h) + && (!preserve_associative||e.common().reactors.is_empty())) + .map(|(h,e)|(h,e.clone())).collect(); + candidates.sort_by_key(|(h,_)|h.value()); + let mut changed=std::collections::HashSet::new(); + if optimize_plines { for (h,e) in &mut candidates { + let before=e.clone(); optimize(e,tolerance); if *e!=before {changed.insert(*h);} + } } + let mut removed=std::collections::HashSet::new(); + // Iterate to a fixed point: a bridge can join two previously disjoint intervals. + loop { + let mut progress=false; + for a in 0..candidates.len() { + if removed.contains(&candidates[a].0) {continue;} + for b in a+1..candidates.len() { + if removed.contains(&candidates[b].0) {continue;} + let left=normalized(&candidates[a].1,ignore); + let right=normalized(&candidates[b].1,ignore); + if left==right { + removed.insert(candidates[b].0);progress=true;continue; + } + let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right) + else {continue;}; + if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;} + let point=|p:acadrust::types::Vector3|[p.x,p.y,p.z]; + let Some(union)=cadkernel::space::line_union( + [point(l.start),point(l.end)],[point(r.start),point(r.end)],tolerance) + else {continue;}; + let allowed=match union.kind { + cadkernel::space::LineUnionKind::Duplicate=>true, + cadkernel::space::LineUnionKind::Overlap=>overlap, + cadkernel::space::LineUnionKind::EndToEnd=>end_to_end, + }; + if !allowed {continue;} + if let acadrust::EntityType::Line(line)=&mut candidates[a].1 { + line.start=acadrust::types::Vector3::new(union.start[0],union.start[1],union.start[2]); + line.end=acadrust::types::Vector3::new(union.end[0],union.end[1],union.end[2]); + } + changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + } } + if !progress {break;} } - if dups.is_empty() { - self.command_line - .push_output(crate::t!("OVERKILL: no duplicate objects found.").as_ref()); - } else { - let n = dups.len(); - self.push_undo_snapshot(i, "OVERKILL"); - self.tabs[i].scene.erase_entities(&dups); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(crate::tf!("OVERKILL: deleted {n} duplicate object(s).").as_ref()); + if !changed.is_empty()||!removed.is_empty() { + self.push_undo_snapshot(i,"OVERKILL"); + let updates:Vec<_>=candidates.into_iter().filter(|(h,_)|changed.contains(h)&&!removed.contains(h)).collect(); + let mut changes = Vec::new(); + for (handle, entity) in updates { + if let Some(target) = self.tabs[i].scene.document.get_entity_mut(handle) { + *target = entity; + changes.push((handle, crate::scene::ChangeKind::Modified)); + } + } + self.tabs[i].scene.bump_entities(&changes); + let handles:Vec<_>=removed.iter().copied().collect(); + self.tabs[i].scene.erase_entities(&handles); + self.tabs[i].dirty=true;self.refresh_properties(); } + self.command_line.push_output(&format!("OVERKILL: removed {} objects; updated {} objects.",removed.len(),changed.len())); } - // ── PICKADD / PICKDRAG — selection UX (#226, app settings) ─── // Bare form reports; ` 0|1` sets and persists. Defaults keep // today's behaviour (PICKADD 1, PICKDRAG 0). diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index a497c4c4e..c5317917f 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -14,6 +14,7 @@ pub mod lengthen; pub mod mirror; pub mod mledit; pub mod offset; +pub mod overkill; pub mod pedit; pub mod block_edit; pub mod refedit; diff --git a/src/modules/draw/modify/overkill.rs b/src/modules/draw/modify/overkill.rs new file mode 100644 index 000000000..842d84104 --- /dev/null +++ b/src/modules/draw/modify/overkill.rs @@ -0,0 +1,133 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; +use crate::command::{CadCommand, CmdOption, CmdResult}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub tolerance: f64, + pub ignore: u16, + pub optimize: bool, + pub overlap: bool, + pub end_to_end: bool, + pub associativity: bool, +} +impl Default for Settings { + fn default() -> Self { Self { tolerance: 1e-6, ignore: 0, optimize: true, + overlap: true, end_to_end: true, associativity: true } } +} +fn remembered() -> &'static std::sync::Mutex { + static SETTINGS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SETTINGS.get_or_init(|| std::sync::Mutex::new(Settings::default())) +} +enum Step { Selection, Options, Ignore, Tolerance, Optimize, Overlap, EndToEnd, Associativity } +pub struct OverkillCommand { selected: Vec, step: Step, settings: Settings } +impl OverkillCommand { + pub fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { Step::Selection } else { Step::Options }; + Self { selected, step, settings: *remembered().lock().unwrap_or_else(|e|e.into_inner()) } + } + fn finish(&self) -> CmdResult { + let s = self.settings; + *remembered().lock().unwrap_or_else(|e|e.into_inner())=s; + CmdResult::Relaunch(format!("OVERKILL_APPLY {} {} {} {} {} {}", s.tolerance, + s.ignore, u8::from(s.optimize), u8::from(s.overlap), u8::from(s.end_to_end), + u8::from(s.associativity)), self.selected.clone()) + } +} +impl CadCommand for OverkillCommand { + fn name(&self) -> &'static str { "OVERKILL" } + fn prompt(&self) -> String { + match self.step { + Step::Selection => "Select objects:".into(), + Step::Options => format!("Tolerance={} Ignore={} Optimize={} Overlap={} End-to-end={}\nEnter an option [Done/Ignore/tOlerance/optimize Plines/combine parTial overlap/combine Endtoend/Associativity] :", self.settings.tolerance, self.settings.ignore, self.settings.optimize, self.settings.overlap, self.settings.end_to_end), + Step::Ignore => "Properties to ignore [None/All/Color/LAyer/Ltype/ltScale/LWeight/Thickness/TRansparency/plotSTyle/Material]:".into(), + Step::Tolerance => format!("Specify tolerance <{}>:", self.settings.tolerance), + Step::Optimize => format!("Optimize segments within polylines [Yes/No] <{}>:",if self.settings.optimize{"Yes"}else{"No"}), + Step::Overlap => format!("Combine partially overlapping objects [Yes/No] <{}>:",if self.settings.overlap{"Yes"}else{"No"}), + Step::EndToEnd => format!("Combine end-to-end objects [Yes/No] <{}>:",if self.settings.end_to_end{"Yes"}else{"No"}), + Step::Associativity => format!("Maintain associative objects [Yes/No] <{}>:",if self.settings.associativity{"Yes"}else{"No"}), + } + } + fn options(&self) -> Vec { + match self.step { + Step::Options => [("Done","D"),("Ignore","I"),("Tolerance","O"),("Optimize polylines","P"),("Combine overlap","T"),("Combine end-to-end","E"),("Associativity","A")].into_iter().map(|(l,k)|CmdOption::new(l,k)).collect(), + Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => vec![CmdOption::new("Yes","Y"),CmdOption::new("No","N")], + _ => Vec::new(), + } + } + fn is_selection_gathering(&self) -> bool { matches!(self.step,Step::Selection) } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { self.selected=handles; CmdResult::NeedPoint } + fn wants_text_input(&self) -> bool { !self.is_selection_gathering() } + fn on_point(&mut self, _:DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Selection if self.selected.is_empty() => CmdResult::Cancel, + Step::Options => self.finish(), + _ => { self.step=Step::Options; CmdResult::NeedPoint } + } + } + fn on_text_input(&mut self, text:&str) -> Option { + let k=text.trim().to_ascii_uppercase(); + match self.step { + Step::Options => match k.as_str() { + "D"|"DONE" => return Some(self.finish()), + "I"|"IGNORE" => self.step=Step::Ignore, + "O"|"TOLERANCE" => self.step=Step::Tolerance, + "P"|"PLINES" => self.step=Step::Optimize, + "T"|"PARTIAL" => self.step=Step::Overlap, + "E"|"ENDTOEND" => self.step=Step::EndToEnd, + "A"|"ASSOCIATIVITY" => self.step=Step::Associativity, + _=>{}, + }, + Step::Ignore => { + let flag=match k.as_str() { "N"|"NONE"=>{self.settings.ignore=0;0}, "A"|"ALL"=>511, + "C"|"COLOR"=>1,"LA"|"LAYER"=>2,"L"|"LTYPE"=>4,"S"|"LTSCALE"=>8, + "LW"|"LWEIGHT"=>16,"T"|"THICKNESS"=>32,"TR"|"TRANSPARENCY"=>64, + "ST"|"PLOTSTYLE"=>128,"M"|"MATERIAL"=>256,_=>return Some(CmdResult::NeedPoint)}; + self.settings.ignore|=flag; self.step=Step::Options; + } + Step::Tolerance => if let Ok(v)=k.parse::() { if v.is_finite()&&v>=0.0 { + self.settings.tolerance=v; self.step=Step::Options; + } }, + Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => { + let yes=match k.as_str(){"Y"|"YES"=>true,"N"|"NO"=>false,_=>return Some(CmdResult::NeedPoint)}; + match self.step {Step::Optimize=>self.settings.optimize=yes,Step::Overlap=>self.settings.overlap=yes, + Step::EndToEnd=>self.settings.end_to_end=yes,Step::Associativity=>self.settings.associativity=yes,_=>{}} + self.step=Step::Options; + } + _=>{}, + } + Some(CmdResult::NeedPoint) + } +} + +pub fn normalized(entity:&EntityType, ignore:u16) -> EntityType { + let mut e=entity.clone(); + let common=e.common_mut(); + let default=acadrust::entities::EntityCommon::new(); + common.handle=Handle::NULL; + if ignore&1!=0 {common.color=default.color;common.color_name=None;common.color_book_handle=None;} + if ignore&2!=0 {common.layer.clear();} + if ignore&4!=0 {common.linetype.clear();common.linetype_handle=None;} + if ignore&8!=0 {common.linetype_scale=1.0;} + if ignore&16!=0 {common.line_weight=default.line_weight;} + if ignore&64!=0 {common.transparency=default.transparency;} + if ignore&128!=0 {common.plotstyle_flags=0;common.plotstyle_handle=None;} + if ignore&256!=0 {common.material_flags=0;common.material_handle=None;} + if ignore&32!=0 {match &mut e {EntityType::Line(v)=>v.thickness=0.0, + EntityType::Circle(v)=>v.thickness=0.0,EntityType::Arc(v)=>v.thickness=0.0, + EntityType::LwPolyline(v)=>v.thickness=0.0,_=>{}}} + e +} + +pub fn optimize(entity:&mut EntityType, tolerance:f64) { + if let EntityType::LwPolyline(p)=entity { + // Width changes and bulged spans carry geometry that must be retained. + if p.vertices.iter().any(|v|v.bulge!=0.0||v.start_width!=0.0||v.end_width!=0.0) {return;} + let points:Vec<_>=p.vertices.iter().map(|v|[v.location.x,v.location.y,p.elevation]).collect(); + let indices=cadkernel::space::simplify_linear_chain(&points,tolerance); + if indices.len()>=if p.is_closed{3}else{2} { + p.vertices=indices.into_iter().map(|i|p.vertices[i].clone()).collect(); + } + } +} From bfac3f130c48bbb0a19a154a6c27752abb1b57c7 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:24:58 +0300 Subject: [PATCH 44/99] Match SPLINE FIT command behavior --- src/modules/draw/draw/spline.rs | 107 +++++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 8ace3cb2d..ca51a07ec 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -26,6 +26,11 @@ pub struct SplineCommand { pts: Vec, control_vertices: bool, choosing_method: bool, + choosing_knots: bool, + choosing_tangent: bool, + knot_parameterization: i32, + begin_tangent: Vector3, + end_tangent: Vector3, } impl SplineCommand { @@ -34,6 +39,11 @@ impl SplineCommand { pts: Vec::new(), control_vertices: false, choosing_method: false, + choosing_knots: false, + choosing_tangent: false, + knot_parameterization: 0, + begin_tangent: Vector3::ZERO, + end_tangent: Vector3::ZERO, } } @@ -48,11 +58,15 @@ impl SplineCommand { if self.pts.len() < 2 { return None; } - Some(EntityType::Spline(make_spline( + let mut spline = make_spline( &self.pts, closed, self.control_vertices, - ))) + ); + spline.knot_parameterization = self.knot_parameterization; + spline.begin_tangent = self.begin_tangent; + spline.end_tangent = self.end_tangent; + Some(EntityType::Spline(spline)) } } @@ -93,10 +107,14 @@ fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { } }; spline.flags.closed = closed; + // Closed fit curves use the kernel's periodic interpolation rather than + // appending a closing straight segment to an open interpolant. + spline.flags.periodic = closed && !control_vertices; spline } /// Preview the exact representation that `build()` commits. +#[cfg(test)] fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32; 3]> { if pts.len() < 2 { return pts @@ -125,6 +143,10 @@ impl CadCommand for SplineCommand { fn prompt(&self) -> String { if self.choosing_method { "SPLINE Choose creation method [Fit/Control vertices]:".into() + } else if self.choosing_knots { + "SPLINE Enter knot parameterization [Chord/Square root/Uniform]:".into() + } else if self.choosing_tangent { + "SPLINE Specify tangent direction:".into() } else if self.pts.is_empty() && self.control_vertices { t!("SPLINE Specify first control point:").into_owned() } else if self.pts.is_empty() { @@ -143,10 +165,18 @@ impl CadCommand for SplineCommand { CmdOption::new("Control vertices", "CV"), ]; } - if self.pts.is_empty() { - return vec![CmdOption::new(t!("Method").as_ref(), "M")]; + if self.choosing_tangent { return vec![]; } + if self.choosing_knots { + return vec![CmdOption::new("Chord", "CH"), CmdOption::new("Square root", "S"), CmdOption::new("Uniform", "U")]; } - let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")]; + if self.pts.is_empty() { + let mut options = vec![CmdOption::new(t!("Method").as_ref(), "M")]; + if !self.control_vertices { options.push(CmdOption::new("Knots", "K")); } + return options; + } + let mut opts = vec![]; + if self.pts.len() >= 3 { opts.push(CmdOption::new(t!("Close").as_ref(), "C")); } + if !self.control_vertices { opts.push(CmdOption::new("Tangency", "T")); } // Undo only makes sense once a control point exists. opts.push(CmdOption::new(t!("Undo").as_ref(), "U")); opts.push(CmdOption::enter(t!("Done").as_ref())); @@ -154,7 +184,22 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.choosing_method || !pt.is_finite() { + if self.choosing_method || self.choosing_knots || !pt.is_finite() { + return CmdResult::NeedPoint; + } + if self.choosing_tangent { + let Some(anchor) = self.pts.last() else { return CmdResult::NeedPoint; }; + let Some(dir) = (pt - *anchor).try_normalize() else { return CmdResult::NeedPoint; }; + let tangent = Vector3::new(dir.x, dir.y, dir.z); + if self.pts.len() == 1 { self.begin_tangent = tangent; } + else { self.end_tangent = tangent; } + self.choosing_tangent = false; + if self.pts.len() > 1 { + return self.build(false).map_or(CmdResult::NeedPoint, CmdResult::CommitAndExit); + } + return CmdResult::NeedPoint; + } + if self.pts.last().is_some_and(|last| last.distance_squared(pt) < 1e-20) { return CmdResult::NeedPoint; } self.pts.push(pt); @@ -162,10 +207,12 @@ impl CadCommand for SplineCommand { } fn on_enter(&mut self) -> CmdResult { - if self.choosing_method { + if self.choosing_method || self.choosing_knots { self.choosing_method = false; + self.choosing_knots = false; return CmdResult::NeedPoint; } + if self.choosing_tangent { return CmdResult::NeedPoint; } match self.build(false) { Some(e) => CmdResult::CommitAndExit(e), None => CmdResult::Cancel, @@ -180,6 +227,15 @@ impl CadCommand for SplineCommand { CmdResult::Cancel } + fn on_undo_step(&mut self) -> Option { + if self.pts.is_empty() { return None; } + self.pts.pop(); + self.end_tangent = Vector3::ZERO; + self.choosing_tangent = false; + if self.pts.is_empty() { self.begin_tangent = Vector3::ZERO; } + Some(CmdResult::NeedPoint) + } + fn wants_text_input(&self) -> bool { true } @@ -189,7 +245,26 @@ impl CadCommand for SplineCommand { } fn on_text_input(&mut self, text: &str) -> Option { + if self.choosing_tangent { return None; } + if self.choosing_knots { + self.knot_parameterization = match text.trim().to_ascii_uppercase().as_str() { + "CH" | "CHORD" => 0, + "S" | "SQUARE" | "SQUAREROOT" => 1, + "U" | "UNIFORM" => 2, + _ => return None, + }; + self.choosing_knots = false; + return Some(CmdResult::NeedPoint); + } match text.trim().to_uppercase().as_str() { + "K" | "KNOTS" if self.pts.is_empty() && !self.control_vertices => { + self.choosing_knots = true; + Some(CmdResult::NeedPoint) + } + "T" | "TANGENCY" if !self.pts.is_empty() && !self.control_vertices => { + self.choosing_tangent = true; + Some(CmdResult::NeedPoint) + } "M" | "METHOD" if self.pts.is_empty() => { self.choosing_method = true; Some(CmdResult::NeedPoint) @@ -204,28 +279,32 @@ impl CadCommand for SplineCommand { self.choosing_method = false; Some(CmdResult::NeedPoint) } - "C" | "CLOSE" => match self.build(true) { + "C" | "CLOSE" if self.pts.len() >= 3 => match self.build(true) { Some(e) => Some(CmdResult::CommitAndExit(e)), None => Some(CmdResult::NeedPoint), }, "U" | "UNDO" => { - self.pts.pop(); - Some(CmdResult::NeedPoint) + self.on_undo_step().or(Some(CmdResult::NeedPoint)) } _ => None, } } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.pts.is_empty() { + if self.pts.is_empty() || self.choosing_method || self.choosing_knots || self.choosing_tangent { return None; } // Preview the committed construction method. - let mut ctrl = self.pts.clone(); - ctrl.push(pt); + self.pts.push(pt); + let entity = self.build(false); + self.pts.pop(); + let Some(EntityType::Spline(spline)) = entity else { return None; }; + let points = crate::entities::curve::spline_curve(&spline) + .map(|curve| crate::entities::curve::curve_points(&curve)) + .unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline)); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl, false, self.control_vertices), + points.into_iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(), WireModel::CYAN, false, )) From b84d387f43d3ca93cd12be9aa26c1221641aea95 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:24:58 +0300 Subject: [PATCH 45/99] Match SPLINE CV command behavior --- src/modules/draw/draw/spline.rs | 57 +++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 8ace3cb2d..7b05899ad 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -26,6 +26,8 @@ pub struct SplineCommand { pts: Vec, control_vertices: bool, choosing_method: bool, + choosing_degree: bool, + degree: usize, } impl SplineCommand { @@ -34,6 +36,8 @@ impl SplineCommand { pts: Vec::new(), control_vertices: false, choosing_method: false, + choosing_degree: false, + degree: 3, } } @@ -48,11 +52,17 @@ impl SplineCommand { if self.pts.len() < 2 { return None; } - Some(EntityType::Spline(make_spline( - &self.pts, - closed, - self.control_vertices, - ))) + let mut spline = make_spline(&self.pts, closed, self.control_vertices); + if self.control_vertices { + let count = spline.control_points.len(); + let degree = self.degree.min(count - 1); + spline.degree = degree as i32; + spline.knots = (0..count + degree + 1).map(|index| { + if index <= degree { 0.0 } else if index >= count { 1.0 } + else { (index - degree) as f64 / (count - degree) as f64 } + }).collect(); + } + Some(EntityType::Spline(spline)) } } @@ -97,6 +107,7 @@ fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { } /// Preview the exact representation that `build()` commits. +#[cfg(test)] fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32; 3]> { if pts.len() < 2 { return pts @@ -123,7 +134,9 @@ impl CadCommand for SplineCommand { } fn prompt(&self) -> String { - if self.choosing_method { + if self.choosing_degree { + format!("SPLINE Enter degree of spline <{}>:", self.degree) + } else if self.choosing_method { "SPLINE Choose creation method [Fit/Control vertices]:".into() } else if self.pts.is_empty() && self.control_vertices { t!("SPLINE Specify first control point:").into_owned() @@ -143,8 +156,11 @@ impl CadCommand for SplineCommand { CmdOption::new("Control vertices", "CV"), ]; } + if self.choosing_degree { return vec![]; } if self.pts.is_empty() { - return vec![CmdOption::new(t!("Method").as_ref(), "M")]; + let mut options = vec![CmdOption::new(t!("Method").as_ref(), "M")]; + if self.control_vertices { options.push(CmdOption::new("Degree", "D")); } + return options; } let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")]; // Undo only makes sense once a control point exists. @@ -154,7 +170,7 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.choosing_method || !pt.is_finite() { + if self.choosing_method || self.choosing_degree || !pt.is_finite() { return CmdResult::NeedPoint; } self.pts.push(pt); @@ -162,7 +178,8 @@ impl CadCommand for SplineCommand { } fn on_enter(&mut self) -> CmdResult { - if self.choosing_method { + if self.choosing_method || self.choosing_degree { + self.choosing_degree = false; self.choosing_method = false; return CmdResult::NeedPoint; } @@ -189,7 +206,16 @@ impl CadCommand for SplineCommand { } fn on_text_input(&mut self, text: &str) -> Option { + if self.choosing_degree { + self.degree = text.trim().parse::().ok().filter(|d| (1..=10).contains(d))?; + self.choosing_degree = false; + return Some(CmdResult::NeedPoint); + } match text.trim().to_uppercase().as_str() { + "D" | "DEGREE" if self.pts.is_empty() && self.control_vertices => { + self.choosing_degree = true; + Some(CmdResult::NeedPoint) + } "M" | "METHOD" if self.pts.is_empty() => { self.choosing_method = true; Some(CmdResult::NeedPoint) @@ -217,15 +243,20 @@ impl CadCommand for SplineCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.pts.is_empty() { + if self.pts.is_empty() || self.choosing_method || self.choosing_degree { return None; } // Preview the committed construction method. - let mut ctrl = self.pts.clone(); - ctrl.push(pt); + self.pts.push(pt); + let entity = self.build(false); + self.pts.pop(); + let Some(EntityType::Spline(spline)) = entity else { return None; }; + let points = crate::entities::curve::spline_curve(&spline) + .map(|curve| crate::entities::curve::curve_points(&curve)) + .unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline)); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl, false, self.control_vertices), + points.into_iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(), WireModel::CYAN, false, )) From a3eee372e49fa8b78cc5d218437f7be250dcae55 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:25:10 +0300 Subject: [PATCH 46/99] Support first-point reset and same-point breaks --- src/modules/draw/modify/break_cmd.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/modify/break_cmd.rs b/src/modules/draw/modify/break_cmd.rs index 2ce6e8460..0ad841512 100644 --- a/src/modules/draw/modify/break_cmd.rs +++ b/src/modules/draw/modify/break_cmd.rs @@ -382,7 +382,7 @@ impl CadCommand for BreakInteractiveCommand { } else if self.p1.is_none() { crate::t!("BREAK Specify first break point:").into_owned() } else { - crate::t!("BREAK Specify second break point:").into_owned() + crate::t!("BREAK Specify second break point or [First point]:").into_owned() } } @@ -390,6 +390,28 @@ impl CadCommand for BreakInteractiveCommand { self.target.is_none() } + fn options(&self) -> Vec { + if self.target.is_some() && self.p1.is_some() { + vec![crate::command::CmdOption::new("First point", "F")] + } else { + Vec::new() + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let handle = self.target?; + match text.trim().to_ascii_uppercase().as_str() { + "F" | "FIRST" => { + self.p1 = None; + Some(CmdResult::NeedPoint) + } + "@" => self.p1.map(|point| CmdResult::BreakEntity { + handle, p1: point, p2: point, + }), + _ => None, + } + } + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; From f80e9f742c8aabef44ca9ed694b3841adf1e9aa5 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:25:11 +0300 Subject: [PATCH 47/99] Reverse selected curves while preserving spline shape --- src/app/commands/draw.rs | 20 +++++++++++++++++--- src/modules/draw/modify/reverse.rs | 14 ++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index cf848ff7b..7c5ce76ce 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -596,9 +596,23 @@ impl OpenCADStudio { "REVERSE" => { use crate::modules::draw::modify::reverse::ReverseCommand; - let new_cmd = ReverseCommand::new(); - self.command_line.push_info(&new_cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + if self.tabs[i].scene.selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new("REVERSE"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + let replacements = self.tabs[i].scene.selected_entities().into_iter() + .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter_map(|(handle, entity)| ReverseCommand::reversed(entity) + .map(|reversed| (handle, vec![reversed]))) + .collect::>(); + if !replacements.is_empty() { + return Some(self.apply_cmd_result(crate::command::CmdResult::ReplaceMany( + replacements, Vec::new(), + ))); + } + } } "MEASUREGEOM" => { diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index 3d7e3d51e..4e2cfdffd 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -54,7 +54,7 @@ impl ReverseCommand { } /// Build a reversed copy of `entity`, or `None` for an unsupported type. - fn reversed(entity: &EntityType) -> Option { + pub fn reversed(entity: &EntityType) -> Option { match entity { EntityType::Line(line) => { let mut out = line.clone(); @@ -176,9 +176,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities out } -/// Reverse a Spline: flip control points and fit points, regenerate the -/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch. -fn reverse_spline(sp: &Spline) -> Spline { +/// Reverse stored coordinates in 3D and let the kernel mirror the knot domain. +pub(super) fn reverse_spline(sp: &Spline) -> Spline { let mut out = sp.clone(); out.control_points.reverse(); out.fit_points.reverse(); @@ -186,8 +185,11 @@ fn reverse_spline(sp: &Spline) -> Spline { if out.weights.len() == out.control_points.len() { out.weights.reverse(); } - out.knots = - Spline::generate_clamped_knots(out.degree as usize, out.control_points.len()); + if let Some(curve) = super::spline_ops::spline_to_nurbs(sp) { + out.knots = curve.reversed().knots().to_vec(); + } + out.begin_tangent = acadrust::types::Vector3::new(-sp.end_tangent.x, -sp.end_tangent.y, -sp.end_tangent.z); + out.end_tangent = acadrust::types::Vector3::new(-sp.begin_tangent.x, -sp.begin_tangent.y, -sp.begin_tangent.z); out } From 2d9befc77c85ef1549ac60d50a2f588d76453a40 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:25:12 +0300 Subject: [PATCH 48/99] Add spline refinement and repeatable editing workflows --- src/modules/draw/modify/reverse.rs | 12 +- src/modules/draw/modify/splinedit.rs | 305 +++++++++++++++------------ 2 files changed, 182 insertions(+), 135 deletions(-) diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index 3d7e3d51e..eabd371b5 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -176,9 +176,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities out } -/// Reverse a Spline: flip control points and fit points, regenerate the -/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch. -fn reverse_spline(sp: &Spline) -> Spline { +/// Reverse stored coordinates in 3D and let the kernel mirror the knot domain. +pub(super) fn reverse_spline(sp: &Spline) -> Spline { let mut out = sp.clone(); out.control_points.reverse(); out.fit_points.reverse(); @@ -186,8 +185,11 @@ fn reverse_spline(sp: &Spline) -> Spline { if out.weights.len() == out.control_points.len() { out.weights.reverse(); } - out.knots = - Spline::generate_clamped_knots(out.degree as usize, out.control_points.len()); + if let Some(curve) = super::spline_ops::spline_to_nurbs(sp) { + out.knots = curve.reversed().knots().to_vec(); + } + out.begin_tangent = acadrust::types::Vector3::new(-sp.end_tangent.x, -sp.end_tangent.y, -sp.end_tangent.z); + out.end_tangent = acadrust::types::Vector3::new(-sp.begin_tangent.x, -sp.begin_tangent.y, -sp.begin_tangent.z); out } diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 6a5852f73..828980f97 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -13,7 +13,6 @@ use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; -use crate::modules::draw::modify::spline_ops::spline_to_nurbs; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,138 +28,217 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy)] enum Step { - /// Waiting for the user to pick a spline entity. SelectSpline, - /// Spline selected; waiting for sub-command text input. - SubCommand { handle: acadrust::Handle }, + Options, + Refine, + Add, + Elevate, + Move { index: usize, refine: bool }, + Weight { index: usize }, + SelectVertex { weight: bool, refine: bool }, } pub struct SplineditCommand { step: Step, + handle: acadrust::Handle, + spline: Option, + pending: Option, + history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, } impl SplineditCommand { pub fn new() -> Self { - Self { - step: Step::SelectSpline, + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new() } + } + + fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { + self.pending = Some(spline.clone()); + CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) + } + + fn refined(&self, point: Option, degree: Option) -> Option { + let source = self.spline.as_ref()?; + let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; + let cadkernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else { return None; }; + if let Some(point) = point { + let projected = planar.plane.project([point.x, point.y, point.z])?; + let nearest = cadkernel::geom2d::closest_point(&cadkernel::geom2d::Curve::Nurbs(curve.clone()), projected); + let (start, end) = curve.domain(); + let parameter = start + nearest.t * (end - start); + if parameter <= start || parameter >= end { return None; } + curve.insert_knot(parameter); } + if let Some(degree) = degree { + if degree <= curve.degree() || degree > 26 { return None; } + curve = curve.elevated(degree - curve.degree())?; + } + let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); + result.control_points = curve.control_points().iter().map(|point| { + let world = planar.plane.point_at(*point); + Vector3::new(world[0], world[1], world[2]) + }).collect(); + Some(result) } } impl CadCommand for SplineditCommand { - fn name(&self) -> &'static str { - "SPLINEDIT" - } - + fn name(&self) -> &'static str { "SPLINEDIT" } fn prompt(&self) -> String { - match &self.step { + match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), - Step::SubCommand { .. } => crate::t!("SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Refine => crate::t!("SPLINEDIT [Add/Elevate order/Move/Weight/eXit] :").into_owned(), + Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), + Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)), + Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] :", index + 1), + Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(), + Step::Weight { index } => format!("SPLINEDIT Vertex {}: enter new weight or [Next/Previous/Select point/eXit] :", index + 1), } } - - fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::SelectSpline) + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + Step::Options => vec![CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], + Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], + Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], + _ => Vec::new(), + } + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectSpline) } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { + self.spline = match entity { EntityType::Spline(spline) => Some(spline), _ => None }; } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() { - return CmdResult::NeedPoint; - } - self.step = Step::SubCommand { handle }; + if handle.is_null() || self.spline.is_none() { return CmdResult::NeedPoint; } + self.handle = handle; + self.step = Step::Options; CmdResult::NeedPoint } - - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::SubCommand { .. }) + fn on_entity_replaced(&mut self, old: acadrust::Handle, new: &[acadrust::Handle]) { + if old == self.handle { + if let (Some(&handle), Some(replacement)) = (new.first(), self.pending.take()) { + if let Some(previous) = self.spline.replace(replacement) { self.history.push((old, previous)); } + self.handle = handle; + } + } } - + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { - let Step::SubCommand { handle } = &self.step else { - return None; - }; - let handle = *handle; - match text.trim().to_uppercase().as_str() { - "CLOSE" | "C" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Close.into_marker(handle)], - )), - "OPEN" | "O" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Open.into_marker(handle)], - )), - "REVERSE" | "R" | "REV" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Reverse.into_marker(handle)], - )), - "EXIT" | "X" | "" => Some(CmdResult::Cancel), - _ => None, + let upper = text.trim().to_uppercase(); + if upper.is_empty() { return Some(self.on_enter()); } + match self.step { + Step::Options => match upper.as_str() { + "R" | "REFINE" => self.step = Step::Refine, + "M" | "MOVE" => self.step = Step::Move { index: 0, refine: false }, + "X" | "EXIT" => return Some(CmdResult::Cancel), + "U" | "UNDO" => { + if let Some((handle, spline)) = self.history.pop() { + self.handle = handle; + self.spline = Some(spline); + return Some(CmdResult::UndoDocument); + } + } + "C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => { + let mut spline = self.spline.clone()?; + let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; + apply_to_spline(&mut spline, op); + return Some(self.replace(spline)); + } + _ => {} + }, + Step::Refine => match upper.as_str() { + "A" | "ADD" => self.step = Step::Add, + "E" | "ELEVATE" => self.step = Step::Elevate, + "M" | "MOVE" => self.step = Step::Move { index: 0, refine: true }, + "W" | "WEIGHT" => self.step = Step::Weight { index: 0 }, + "X" | "EXIT" => self.step = Step::Options, + _ => {} + }, + Step::Elevate => { + if let Some(spline) = upper.parse::().ok().and_then(|degree| self.refined(None, Some(degree))) { + self.step = Step::Refine; + return Some(self.replace(spline)); + } + } + Step::Move { index, .. } | Step::Weight { index } => { + let count = self.spline.as_ref().map_or(0, |s| s.control_points.len()); + if count == 0 { return Some(CmdResult::NeedPoint); } + let next = match upper.as_str() { "N" | "NEXT" => Some((index + 1) % count), "P" | "PREVIOUS" => Some((index + count - 1) % count), _ => None }; + if let Some(next) = next { + self.step = match self.step { Step::Move { refine, .. } => Step::Move { index: next, refine }, _ => Step::Weight { index: next } }; + } else if matches!(upper.as_str(), "S" | "SELECT") { + self.step = match self.step { + Step::Move { refine, .. } => Step::SelectVertex { weight: false, refine }, + _ => Step::SelectVertex { weight: true, refine: true }, + }; + } else if matches!(upper.as_str(), "X" | "EXIT") { + self.step = match self.step { Step::Move { refine: false, .. } => Step::Options, _ => Step::Refine }; + } else if matches!(self.step, Step::Weight { .. }) { + if let Some(weight) = upper.replace(',', ".").parse::().ok().filter(|weight| weight.is_finite() && *weight > 0.0) { + let mut spline = self.spline.clone()?; + spline.weights.resize(count, 1.0); + spline.weights[index] = weight; + spline.flags.rational = true; + spline.fit_points.clear(); + return Some(self.replace(spline)); + } + } + } + _ => {} } + Some(CmdResult::NeedPoint) } - - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !point.is_finite() { return CmdResult::NeedPoint; } + match self.step { + Step::SelectVertex { weight, refine } => { + if let Some(index) = self.spline.as_ref().and_then(|spline| spline.control_points.iter().enumerate() + .min_by(|(_, a), (_, b)| { + let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); + distance(a).total_cmp(&distance(b)) + }).map(|(index, _)| index)) { + self.step = if weight { Step::Weight { index } } else { Step::Move { index, refine } }; + } + CmdResult::NeedPoint + } + Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), + Step::Move { index, .. } => { + let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; }; + let Some(vertex) = spline.control_points.get_mut(index) else { return CmdResult::NeedPoint; }; + *vertex = Vector3::new(point.x, point.y, point.z); + spline.fit_points.clear(); + self.replace(spline) + } + _ => CmdResult::NeedPoint, + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } - fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - vec![] + match self.step { + Step::SelectSpline | Step::Options => CmdResult::Cancel, + Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } + Step::Add | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } + Step::Elevate => { + let degree = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); + self.on_text_input(°ree.to_string()).unwrap_or(CmdResult::NeedPoint) + } + Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), + } } + fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { vec![] } } - -// ── Spline operation helpers ─────────────────────────────────────────────── -// -// Because `CmdResult::ReplaceEntity` takes `Vec` and we need the -// host to apply it to the actual document, we use a small "deferred op" trick: -// the host applies the spline transform in `apply_cmd_result` by detecting -// that we replaced an entity with zero new entities (delete) or one new one. -// -// Here we actually build the modified entity without accessing the document — -// we only have the handle. The real transformation (close/open/reverse) is -// applied in the Scene via `apply_spline_op`. - -enum SplineOp { - Close, - Open, - Reverse, -} - -impl SplineOp { - /// Return a placeholder that encodes the op in a comment field. - /// The host `cmd_result.rs` detects SPLINEDIT replaces and calls - /// `Scene::apply_spline_op`. - fn into_marker(self, _handle: acadrust::Handle) -> EntityType { - // We can't build the modified spline here without the document. - // Return a sentinel — the actual transformation is handled by the - // apply_spline_op path in Scene. We use an XLine as a sentinel - // that the host will never actually commit (it detects the SPLINEDIT - // replace path and calls apply_spline_op instead). - // - // In practice: use ReplaceMany with empty new entities + a SplineOp - // side-channel via the xattach_path mechanism isn't clean. - // - // Simpler: encode op in the sentinel entity's layer field. - let mut sentinel = acadrust::entities::XLine::new( - acadrust::types::Vector3::zero(), - acadrust::types::Vector3::new(1.0, 0.0, 0.0), - ); - sentinel.common.layer = match self { - SplineOp::Close => "__SPLINEDIT_CLOSE__".to_string(), - SplineOp::Open => "__SPLINEDIT_OPEN__".to_string(), - SplineOp::Reverse => "__SPLINEDIT_REVERSE__".to_string(), - }; - EntityType::XLine(sentinel) - } -} - /// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity. /// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected. pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, op: &str) { let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else { return; }; + apply_to_spline(spline, op); +} + +fn apply_to_spline(spline: &mut acadrust::entities::Spline, op: &str) { match op { "__SPLINEDIT_CLOSE__" => { if spline.control_points.len() >= 2 { @@ -193,40 +271,7 @@ pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle } } "__SPLINEDIT_REVERSE__" => { - spline.fit_points.reverse(); - let begin = spline.begin_tangent; - spline.begin_tangent = spline.end_tangent; - spline.end_tangent = begin; - // The knots mirror within the domain rather than being - // regenerated. Rebuilding a clamped *uniform* vector here changed - // the shape of any spline whose knots were unevenly spaced — - // which is most of them once a modeller has been near one. - match spline_to_nurbs(spline).map(|curve| curve.reversed()) { - Some(reversed) => { - let elevation = spline - .control_points - .first() - .or_else(|| spline.fit_points.first()) - .map(|p| p.z) - .unwrap_or(0.0); - spline.degree = reversed.degree() as i32; - spline.knots = reversed.knots().to_vec(); - spline.control_points = reversed - .control_points() - .iter() - .map(|p| Vector3::new(p[0], p[1], elevation)) - .collect(); - spline.weights = if reversed.is_rational() { - reversed.weights().to_vec() - } else { - Vec::new() - }; - } - // Nothing the kernel can read — a spline with too few points - // to describe a curve. Reversing its stored points is still - // the honest answer. - None => spline.control_points.reverse(), - } + *spline = super::reverse::reverse_spline(spline); } _ => {} } From 376faec4db2031622bf4efa6e4c320458a28b767 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:25:12 +0300 Subject: [PATCH 49/99] Add polyline vertex and multiple-object editing --- src/app/command_driver.rs | 45 +++++- src/app/commands/inquiry.rs | 1 + src/modules/draw/modify/pedit.rs | 215 +++++++++++++++++++++++++++-- src/modules/draw/modify/reverse.rs | 2 +- 4 files changed, 249 insertions(+), 14 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..7737ae7cc 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2312,8 +2312,12 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_dim_block_recorded(nh); } } + let pedit_entities = if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { + new_handles.iter().filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()).collect::>() + } else { Vec::new() }; if let Some(cmd) = &mut self.tabs[i].active_cmd { cmd.on_entity_replaced(handle, &new_handles); + for entity in pedit_entities { cmd.inject_picked_entity(entity); } } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -3205,13 +3209,44 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } CmdResult::PeditOp { handle, op } => { - if self.reject_locked_edit(i, handle) { + if !matches!(&op, crate::modules::draw::modify::pedit::PeditOp::Multiple(_, _)) && self.reject_locked_edit(i, handle) { return Task::none(); } use crate::modules::draw::modify::pedit::{ apply_pedit, convert_to_polyline, PeditOp, }; match &op { + PeditOp::Multiple(handles, operation) => { + let replacements: Vec<_> = handles.iter().filter(|handle| !self.tabs[i].scene.is_layer_locked(**handle)).filter_map(|handle| { + let original = self.tabs[i].scene.document.get_entity(*handle)?; + if matches!(operation.as_ref(), PeditOp::ConvertToPolyline) { + convert_to_polyline(original).map(|replacement| (*handle, replacement, true)) + } else { + let mut replacement = original.clone(); + apply_pedit(&mut replacement, operation).then_some((*handle, replacement, false)) + } + }).collect(); + if replacements.is_empty() { return Task::none(); } + self.push_undo_snapshot(i, "PEDIT"); + for (handle, replacement, converted) in replacements { + let updated_handle = if converted { + self.tabs[i].scene.erase_entities(&[handle]); + let new_handle = self.tabs[i].scene.add_entity(replacement); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_entity_replaced(handle, &[new_handle]); } + new_handle + } else { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { *entity = replacement; } + self.tabs[i].scene.refresh_fill_model(handle); + self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + handle + }; + let updated = self.tabs[i].scene.document.get_entity(updated_handle).cloned(); + if let (Some(command), Some(entity)) = (self.tabs[i].active_cmd.as_mut(), updated) { command.inject_picked_entity(entity); } + } + if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_pedit_applied(); } + self.tabs[i].dirty = true; + self.refresh_properties(); + } // The convert replaces the entity (new handle). PeditOp::ConvertToPolyline => { let converted = self.tabs[i] @@ -3240,7 +3275,9 @@ impl OpenCADStudio { .map(|e| apply_pedit(e, &op)) .unwrap_or(false); if changed { + let updated = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(command) = self.tabs[i].active_cmd.as_mut() { + if let Some(entity) = updated { command.inject_picked_entity(entity); } command.on_pedit_applied(); } self.tabs[i].dirty = true; @@ -4749,6 +4786,12 @@ impl OpenCADStudio { let active = self.tabs[i].active_cmd.take(); self.undo_active_tab(); self.tabs[i].active_cmd = active; + if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { + let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect(); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + for entity in entities { command.inject_picked_entity(entity); } + } + } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 08f920696..63de892f9 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -294,6 +294,7 @@ impl OpenCADStudio { header.surface_u_density, header.surface_v_density, ) + .with_entities(self.tabs[i].scene.document.entities().cloned()) .with_preselection(&preselected); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 54e53e178..8f6c3214a 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -29,10 +29,16 @@ pub struct PeditTarget { enum Mode { PickTarget, + MultipleGather, + MultipleConvert, Options, /// Picked a Line/Arc: asking "Turn it into one? [Yes/No]". ConvertPrompt(Handle), AwaitWidth, + AwaitLinetype, + PolyVertex(usize), + PolyMove(usize), + PolyInsert(usize), /// Join: gathering additional segments; Enter merges. JoinGather(Vec), /// Polygon-mesh vertex navigation uses the mesh's row-major control net. @@ -43,6 +49,10 @@ enum Mode { pub struct PeditCommand { target: Option, + entities: HashMap, + multiple: Vec, + multiple_history: Vec>, + pending_multiple: Option>, info: HashMap, mode: Mode, undo_count: usize, @@ -69,6 +79,10 @@ impl PeditCommand { }; Self { target: None, + entities: HashMap::default(), + multiple: Vec::new(), + multiple_history: Vec::new(), + pending_multiple: None, info, mode: Mode::PickTarget, undo_count: 0, @@ -86,6 +100,17 @@ impl PeditCommand { /// Adopt a pre-selected entity (pickfirst): a selected polyline skips the /// pick step, a selected line/arc goes straight to the convert prompt. pub fn with_preselection(mut self, handles: &[Handle]) -> Self { + let multiple: Vec<_> = handles.iter().copied().filter(|handle| { + self.info.get(&handle.value()).is_some_and(|info| info.mesh_size.is_none()) + }).collect(); + if multiple.len() > 1 { + self.target = multiple.first().copied(); + self.mode = if multiple.iter().any(|handle| self.info.get(&handle.value()).is_some_and(|info| info.convertible)) { + Mode::MultipleConvert + } else { Mode::Options }; + self.multiple = multiple; + return self; + } for &h in handles { let Some(info) = self.info.get(&h.value()).copied() else { continue; @@ -103,6 +128,26 @@ impl PeditCommand { self } + pub fn with_entities(mut self, entities: impl IntoIterator) -> Self { + self.entities = entities.into_iter().map(|entity| (entity.common().handle.value(), entity)).collect(); + self + } + + fn multiple_result(&self, result: Option) -> Option { + match result { + Some(CmdResult::PeditOp { handle, op }) if !self.multiple.is_empty() => + Some(CmdResult::PeditOp { handle, op: PeditOp::Multiple(self.multiple.clone(), Box::new(op)) }), + other => other, + } + } + + fn vertex_count(&self) -> usize { + self.target.and_then(|handle| self.entities.get(&handle.value())).map_or(0, |entity| match entity { + EntityType::LwPolyline(polyline) => polyline.vertices.len(), + EntityType::Polyline2D(polyline) => polyline.vertices.len(), + _ => 0, + }) + } fn mesh_size(&self) -> Option<(usize, usize)> { let handle = self.target?; self.info.get(&handle.value())?.mesh_size @@ -148,13 +193,16 @@ impl CadCommand for PeditCommand { fn prompt(&self) -> String { match &self.mode { Mode::PickTarget => { - t!("PEDIT Select polyline (or a line/arc to convert):").into_owned() + t!("PEDIT Select polyline (or a line/arc to convert) or [Multiple]:").into_owned() } + Mode::MultipleGather => format!("PEDIT Select objects ({} selected, Enter when done):", self.multiple.len()), + Mode::MultipleConvert => t!("PEDIT Convert lines and arcs to polylines [Yes/No] :").into_owned(), Mode::ConvertPrompt(_) => t!( "PEDIT Object is not a polyline. Turn it into one? [Yes/No] :" ) .into_owned(), Mode::AwaitWidth => t!("PEDIT Specify new width:").into_owned(), + Mode::AwaitLinetype => t!("PEDIT Enter polyline linetype generation option [ON/OFF]:").into_owned(), Mode::JoinGather(list) => t!( "PEDIT Join Select objects to join (%{count} picked), Enter to merge:", count = list.len().saturating_sub(1) @@ -170,6 +218,9 @@ impl CadCommand for PeditCommand { vertex = index + 1 ) .into_owned(), + Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Insert/Move/eXit] :", index + 1), + Mode::PolyMove(index) => format!("PEDIT Specify new location for vertex {}:", index + 1), + Mode::PolyInsert(index) => format!("PEDIT Specify location after vertex {}:", index + 1), Mode::Options => t!("PEDIT Enter option:").into_owned(), } } @@ -177,6 +228,8 @@ impl CadCommand for PeditCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match &self.mode { + Mode::PickTarget => vec![CmdOption::new("Multiple", "M")], + Mode::MultipleConvert => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], Mode::Options if self.mesh_size().is_some() => { let (closed_m, closed_n) = self.mesh_closed().unwrap_or((false, false)); vec![ @@ -205,8 +258,12 @@ impl CadCommand for PeditCommand { CmdOption::new(t!("Fit").as_ref(), "F"), CmdOption::new(t!("Spline").as_ref(), "S"), CmdOption::new(t!("Decurve").as_ref(), "D"), + CmdOption::new(t!("Edit vertex").as_ref(), "E"), + CmdOption::new(t!("Ltype gen").as_ref(), "L"), + CmdOption::new(t!("Reverse").as_ref(), "R"), + CmdOption::new(t!("Undo").as_ref(), "U"), CmdOption::new(t!("eXit").as_ref(), "X"), - ], + ].into_iter().filter(|option| self.multiple.is_empty() || option.keyword != "E").collect(), Mode::MeshVertex(_) => vec![ CmdOption::new(t!("Next").as_ref(), "N"), CmdOption::new(t!("Previous").as_ref(), "P"), @@ -222,6 +279,8 @@ impl CadCommand for PeditCommand { vec![CmdOption::new(t!("Yes").as_ref(), "Y"), CmdOption::new(t!("No").as_ref(), "N")] } Mode::JoinGather(_) => vec![CmdOption::enter(t!("Join").as_ref())], + Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Exit", "X")], + Mode::AwaitLinetype => vec![CmdOption::new("On", "ON"), CmdOption::new("Off", "OFF")], Mode::MeshVertexMove(_) => vec![], _ => vec![], } @@ -234,10 +293,14 @@ impl CadCommand for PeditCommand { fn is_selection_gathering(&self) -> bool { // Join uses the normal selection system, so single picks AND // window/crossing boxes both gather objects. - matches!(self.mode, Mode::JoinGather(_)) + matches!(self.mode, Mode::JoinGather(_) | Mode::MultipleGather) } fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if matches!(self.mode, Mode::MultipleGather) { + self.multiple = handles.into_iter().filter(|handle| self.info.get(&handle.value()).is_some_and(|info| info.mesh_size.is_none())).collect(); + return CmdResult::NeedPoint; + } if let (Some(target), Mode::JoinGather(list)) = (self.target, &mut self.mode) { list.clear(); list.push(target); @@ -275,7 +338,8 @@ impl CadCommand for PeditCommand { // A Yes-conversion (or Break) replaced the entity — adopt the first // piece as the live target and carry its bookkeeping over. if let Some(&nh) = new_handles.first() { - self.info.remove(&old.value()); + if self.multiple.is_empty() { self.info.remove(&old.value()); } + for handle in &mut self.multiple { if *handle == old { *handle = nh; } } self.info.insert( nh.value(), PeditTarget { @@ -290,8 +354,13 @@ impl CadCommand for PeditCommand { } } + fn inject_picked_entity(&mut self, entity: EntityType) { + self.entities.insert(entity.common().handle.value(), entity); + } + fn on_pedit_applied(&mut self) { self.undo_count = self.undo_count.saturating_add(1); + self.multiple_history.push(self.pending_multiple.take().unwrap_or_else(|| self.multiple.clone())); if let Some((m_direction, closed)) = self.pending_mesh_closed.take() { let previous = self.mesh_closed(); self.mesh_closed_history.push(previous); @@ -302,15 +371,35 @@ impl CadCommand for PeditCommand { } fn wants_text_input(&self) -> bool { - !matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_)) + !matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_) | Mode::PolyMove(_) | Mode::PolyInsert(_)) } fn on_text_input(&mut self, text: &str) -> Option { self.pending_mesh_closed = None; let up = text.trim().to_uppercase(); let mesh_size = self.mesh_size(); - match &mut self.mode { - Mode::PickTarget => None, + let vertex_count = self.vertex_count(); + let result = match &mut self.mode { + Mode::PickTarget => { + if matches!(up.as_str(), "M" | "MULTIPLE") { self.mode = Mode::MultipleGather; Some(CmdResult::NeedPoint) } else { None } + } + Mode::MultipleGather => None, + Mode::MultipleConvert => { + match up.as_str() { + "Y" | "YES" | "" => { + self.pending_multiple = Some(self.multiple.clone()); + self.mode = Mode::Options; + Some(CmdResult::PeditOp { handle: *self.multiple.first()?, op: PeditOp::ConvertToPolyline }) + } + "N" | "NO" => { + self.multiple.retain(|handle| self.info.get(&handle.value()).is_some_and(|info| info.is_poly)); + self.target = self.multiple.first().copied(); + self.mode = Mode::Options; + Some(if self.target.is_some() { CmdResult::NeedPoint } else { CmdResult::Cancel }) + } + _ => Some(CmdResult::NeedPoint), + } + } Mode::ConvertPrompt(handle) => { let handle = *handle; match up.as_str() { @@ -331,14 +420,31 @@ impl CadCommand for PeditCommand { .replace(',', ".") .parse() .ok() - .filter(|&v: &f64| v >= 0.0)?; + .filter(|&v: &f64| v.is_finite() && v >= 0.0)?; self.mode = Mode::Options; Some(CmdResult::PeditOp { handle, op: PeditOp::SetWidth(w), }) } + Mode::AwaitLinetype => { + let enabled = match up.as_str() { "ON" => true, "OFF" => false, _ => return Some(CmdResult::NeedPoint) }; + self.mode = Mode::Options; + Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::SetLinetypeGeneration(enabled) }) + } Mode::JoinGather(_) => None, + Mode::PolyMove(_) | Mode::PolyInsert(_) => None, + Mode::PolyVertex(index) => { + match up.as_str() { + "N" | "NEXT" => { if *index + 1 < vertex_count { *index += 1; } } + "P" | "PREVIOUS" => { *index = index.saturating_sub(1); } + "I" | "INSERT" => self.mode = Mode::PolyInsert(*index), + "M" | "MOVE" => self.mode = Mode::PolyMove(*index), + "X" | "EXIT" => self.mode = Mode::Options, + _ => {} + } + Some(CmdResult::NeedPoint) + } Mode::MeshVertexMove(_) => None, Mode::MeshVertex(index) => { let (m, n) = mesh_size?; @@ -442,6 +548,19 @@ impl CadCommand for PeditCommand { }; } match up.as_str() { + "E" | "EDIT" if self.multiple.is_empty() => { self.mode = Mode::PolyVertex(0); Some(CmdResult::NeedPoint) } + "R" | "REVERSE" => Some(CmdResult::PeditOp { handle, op: PeditOp::Reverse }), + "L" | "LTYPE" | "LTYPEGEN" => { self.mode = Mode::AwaitLinetype; Some(CmdResult::NeedPoint) } + "U" | "UNDO" if self.undo_count > 0 => { + self.undo_count -= 1; + self.mesh_closed_history.pop(); + if let Some(previous) = self.multiple_history.pop() { + self.multiple = previous; + if !self.multiple.is_empty() { self.target = self.multiple.first().copied(); } + } + Some(CmdResult::UndoDocument) + } + "U" | "UNDO" => Some(CmdResult::NeedPoint), "X" | "EXIT" => Some(CmdResult::Cancel), "C" | "CLOSE" => Some(CmdResult::PeditOp { handle, @@ -475,21 +594,29 @@ impl CadCommand for PeditCommand { // Inline shorthand `W `. if let Some(rest) = up.strip_prefix("W ") { let w: f64 = rest.trim().replace(',', ".").parse().ok()?; - if w >= 0.0 { - return Some(CmdResult::PeditOp { + if w.is_finite() && w >= 0.0 { + return self.multiple_result(Some(CmdResult::PeditOp { handle, op: PeditOp::SetWidth(w), - }); + })); } } None } } } - } + }; + self.multiple_result(result) } fn on_point(&mut self, point: DVec3) -> CmdResult { + if let Mode::PolyMove(index) | Mode::PolyInsert(index) = self.mode { + if !point.is_finite() { return CmdResult::NeedPoint; } + let Some(handle) = self.target else { return CmdResult::Cancel; }; + let insert = matches!(self.mode, Mode::PolyInsert(_)); + self.mode = Mode::PolyVertex(index); + return CmdResult::PeditOp { handle, op: PeditOp::EditVertex { index, point, insert } }; + } if let Mode::MeshVertexMove(index) = self.mode { let Some(handle) = self.target else { return CmdResult::Cancel; @@ -507,6 +634,18 @@ impl CadCommand for PeditCommand { let mesh_size = self.mesh_size(); let vertex_default = self.mesh_vertex_default; match &mut self.mode { + Mode::MultipleGather => { + self.target = self.multiple.first().copied(); + if self.target.is_none() { return CmdResult::Cancel; } + self.mode = if self.multiple.iter().any(|handle| self.info.get(&handle.value()).is_some_and(|info| info.convertible)) { + Mode::MultipleConvert + } else { Mode::Options }; + CmdResult::NeedPoint + } + Mode::MultipleConvert => self.on_text_input("Y").unwrap_or(CmdResult::NeedPoint), + Mode::PolyVertex(_) => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), + Mode::PolyMove(_) | Mode::PolyInsert(_) => { self.mode = Mode::Options; CmdResult::NeedPoint } + Mode::AwaitLinetype => { self.mode = Mode::Options; CmdResult::NeedPoint } Mode::JoinGather(list) if list.len() >= 2 => CmdResult::JoinEntities(list.clone()), Mode::JoinGather(_) => { self.mode = Mode::Options; @@ -539,8 +678,12 @@ impl CadCommand for PeditCommand { #[derive(Clone)] pub enum PeditOp { + Multiple(Vec, Box), SetClosed(bool), SetWidth(f64), + SetLinetypeGeneration(bool), + EditVertex { index: usize, point: DVec3, insert: bool }, + Reverse, /// Replace the picked Line/Arc with an equivalent LwPolyline (#263). ConvertToPolyline, Fit, @@ -560,6 +703,54 @@ pub enum PeditOp { pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { + PeditOp::Multiple(_, _) => false, + PeditOp::EditVertex { index, point, insert } => { + if !point.is_finite() { return false; } + match entity { + EntityType::LwPolyline(polyline) => { + let plane = crate::entities::curve::ocs_plane(polyline.normal, polyline.elevation); + let Some(local) = plane.project([point.x, point.y, point.z]) else { return false; }; + if *index >= polyline.vertices.len() { return false; } + if *insert { + polyline.vertices.insert(index + 1, LwVertex::new(Vector2::new(local[0], local[1]))); + } else { polyline.vertices[*index].location = Vector2::new(local[0], local[1]); } + true + } + EntityType::Polyline2D(polyline) => { + let plane = crate::entities::curve::ocs_plane(polyline.normal, polyline.elevation); + let Some(local) = plane.project([point.x, point.y, point.z]) else { return false; }; + if *index >= polyline.vertices.len() { return false; } + let location = Vector3::new(local[0], local[1], polyline.elevation); + if *insert { + polyline.vertices.insert(index + 1, acadrust::entities::polyline::Vertex2D::new(location)); + } else { polyline.vertices[*index].location = location; } + true + } + _ => false, + } + } + PeditOp::Reverse => { + let Some(reversed) = super::reverse::ReverseCommand::reversed(entity) else { return false; }; + *entity = reversed; + true + } + PeditOp::SetLinetypeGeneration(enabled) => match entity { + EntityType::LwPolyline(polyline) => { + if polyline.plinegen == *enabled { return false; } + polyline.plinegen = *enabled; + true + } + EntityType::Polyline2D(polyline) => { + let flag = acadrust::entities::polyline::PolylineFlags::LINETYPE_CONTINUOUS; + let bits = polyline.flags.bits(); + if (bits & flag.bits() != 0) == *enabled { return false; } + polyline.flags = acadrust::entities::polyline::PolylineFlags::from_bits( + if *enabled { bits | flag.bits() } else { bits & !flag.bits() } + ); + true + } + _ => false, + }, PeditOp::SetClosed(closed) => match entity { EntityType::LwPolyline(p) => { p.is_closed = *closed; diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index 3d7e3d51e..ec1a6766f 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -54,7 +54,7 @@ impl ReverseCommand { } /// Build a reversed copy of `entity`, or `None` for an unsupported type. - fn reversed(entity: &EntityType) -> Option { + pub fn reversed(entity: &EntityType) -> Option { match entity { EntityType::Line(line) => { let mut out = line.clone(); From 4392244387333daa109762cefb6df7dfaecd1796 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:25:13 +0300 Subject: [PATCH 50/99] Gather reference sets for relative draw order --- src/app/commands/view.rs | 200 ++++++++++++++++++++++----------------- 1 file changed, 115 insertions(+), 85 deletions(-) diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 2ac432f68..88c7dc5e1 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -941,6 +941,37 @@ impl OpenCADStudio { return Some(Task::none()); } + "DRAWORDER_FRONT" | "DRAWORDER_BACK" | "DRAWORDER_ABOVE" | "DRAWORDER_UNDER" => { + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + if selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::plain("DRAWORDER", cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else if matches!(cmd, "DRAWORDER_ABOVE" | "DRAWORDER_UNDER") { + let command = DrawOrderCommand::for_reference_pick( + selected, + cmd == "DRAWORDER_ABOVE", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + let primitive = if cmd == "DRAWORDER_FRONT" { + "DRAWORDER FRONT" + } else { + "DRAWORDER BACK" + }; + return Some(self.apply_cmd_result(crate::command::CmdResult::Relaunch( + primitive.to_string(), + selected, + ))); + } + } "DRAWORDER" => { let selected: Vec = self.tabs[i] .scene @@ -968,25 +999,29 @@ impl OpenCADStudio { self.command_line .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { - // Parse relative target handle for ABOVE/UNDER. - let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() { - "A" | "ABOVE" => { - let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok()); - h_val.map(|v| (true, acadrust::Handle::new(v))) - } - "U" | "UNDER" | "BELOW" => { - let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok()); - h_val.map(|v| (false, acadrust::Handle::new(v))) - } + let relative_above = match option.as_str() { + "A" | "ABOVE" => Some(true), + "U" | "UNDER" | "BELOW" => Some(false), _ => None, }; + let references: Vec<_> = parts.iter().skip(2).filter_map(|text| { + u64::from_str_radix(text.trim_start_matches("0x").trim_start_matches("0X"), 16) + .ok().map(acadrust::Handle::new) + }).filter(|handle| !selected.contains(handle)).collect(); + let relative_assignments = relative_above.and_then(|above| { + assign_relative_group_keys( + &self.tabs[i].scene.document, + self.tabs[i].scene.current_layout_block_handle_pub(), + &selected, &references, above, + ) + }); let to_front_opt = match option.as_str() { "F" | "FRONT" => Some(true), "B" | "BACK" => Some(false), _ => None, }; - if relative_target.is_some() || to_front_opt.is_some() { + if relative_assignments.is_some() || to_front_opt.is_some() { self.push_undo_snapshot(i, "DRAWORDER"); let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); @@ -1083,43 +1118,11 @@ impl OpenCADStudio { }); if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { - if let Some((above, target)) = relative_target { - // move_above/move_below recompute target±1 per - // call, so looping them over N selected entities - // ties the whole group onto one key. Read the - // reference key once and hand out per-index - // keys instead, keeping the moved entities a - // distinct, selection-ordered block adjacent to - // the reference. - let target_sort = match table.get_sort_handle(target) { - Some(h) => h.value(), - None => { - // A reference object that was never - // reordered isn't in the table yet; - // seed it with its own handle as the - // implicit sort key. - table.add_entry(target, target); - table - .get_sort_handle(target) - .map_or(target.value(), |h| h.value()) - } - }; - for (k, h) in selected.iter().enumerate() { - let offset = 1 + k as u64; - let sort = if above { - target_sort.saturating_add(offset) - } else { - target_sort.saturating_sub(offset).max(1) - }; - table.add_entry(*h, acadrust::Handle::new(sort)); + if let Some(assignments) = &relative_assignments { + for (handle, sort) in assignments { + table.add_entry(*handle, acadrust::Handle::new(*sort)); } - let rel = if above { "above" } else { "below" }; - self.command_line.push_info(crate::tf!( - "DRAWORDER: moved {} entities {} {:x}.", - selected.len(), - rel, - target.value() - ).as_ref()); + self.command_line.push_info(crate::t!("DRAWORDER: selection reordered relative to reference objects.").as_ref()); } else if let Some(to_front) = to_front_opt { if to_front { let (_, max_eff) = fb_baseline.unwrap_or((1, 0)); @@ -1250,11 +1253,12 @@ enum DrawOrderStep { /// - `Front` / `F`: moves selection to front. /// - `Back` / `B` / Enter: moves selection to back. /// - `Above` / `A` / `Under` / `U`: advances to reference object pick. -/// 3. Reference object pick: user can click the reference entity in the viewport -/// or type its hex handle on the command line. +/// 3. Gather reference objects, excluding the moved selection; Enter applies. +/// Typed hexadecimal handles may also be accumulated before confirming. pub(crate) struct DrawOrderCommand { selected: Vec, step: DrawOrderStep, + references: Vec, } impl DrawOrderCommand { @@ -1264,13 +1268,14 @@ impl DrawOrderCommand { } else { DrawOrderStep::ChooseVerb }; - Self { selected, step } + Self { selected, step, references: Vec::new() } } pub(crate) fn for_reference_pick(selected: Vec, above: bool) -> Self { Self { selected, step: DrawOrderStep::PickReference { above }, + references: Vec::new(), } } } @@ -1289,10 +1294,10 @@ impl CadCommand for DrawOrderCommand { crate::t!("DRAWORDER [Above / Under / Front / Back] :").into_owned() } DrawOrderStep::PickReference { above: true } => { - crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() + format!("DRAWORDER Select reference objects to move above ({} selected, Enter when done):", self.references.len()) } DrawOrderStep::PickReference { above: false } => { - crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + format!("DRAWORDER Select reference objects to move under ({} selected, Enter when done):", self.references.len()) } } } @@ -1310,7 +1315,7 @@ impl CadCommand for DrawOrderCommand { } fn input_kind(&self) -> crate::command::InputKind { - if matches!(self.step, DrawOrderStep::SelectObjects) { + if matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) { crate::command::InputKind::Point } else { crate::command::InputKind::SingleToken @@ -1318,11 +1323,15 @@ impl CadCommand for DrawOrderCommand { } fn is_selection_gathering(&self) -> bool { - matches!(self.step, DrawOrderStep::SelectObjects) + matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) } fn on_selection_complete(&mut self, handles: Vec) -> crate::command::CmdResult { - self.selected = handles; + if matches!(self.step, DrawOrderStep::PickReference { .. }) { + self.references = handles.into_iter().filter(|handle| !self.selected.contains(handle)).collect(); + } else { + self.selected = handles; + } crate::command::CmdResult::NeedPoint } @@ -1341,7 +1350,12 @@ impl CadCommand for DrawOrderCommand { let handles = std::mem::take(&mut self.selected); crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles) } - DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel, + DrawOrderStep::PickReference { above } => { + if self.references.is_empty() { return crate::command::CmdResult::Cancel; } + let option = if above { "ABOVE" } else { "UNDER" }; + let references = self.references.iter().map(|handle| format!("{:x}", handle.value())).collect::>().join(" "); + crate::command::CmdResult::Relaunch(format!("DRAWORDER {option} {references}"), std::mem::take(&mut self.selected)) + } } } @@ -1374,47 +1388,63 @@ impl CadCommand for DrawOrderCommand { _ => Some(crate::command::CmdResult::NeedPoint), } } - DrawOrderStep::PickReference { above } => { - let hex_str = t.trim_start_matches("0x").trim_start_matches("0X"); - if let Ok(val) = u64::from_str_radix(hex_str, 16) { - let opt = if above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, val); - let handles = std::mem::take(&mut self.selected); - Some(crate::command::CmdResult::Relaunch(cmd, handles)) - } else { - Some(crate::command::CmdResult::NeedPoint) + DrawOrderStep::PickReference { .. } => { + for text in t.split_whitespace() { + let hex = text.trim_start_matches("0x").trim_start_matches("0X"); + if let Ok(value) = u64::from_str_radix(hex, 16) { + let handle = acadrust::Handle::new(value); + if !handle.is_null() && !self.selected.contains(&handle) && !self.references.contains(&handle) { + self.references.push(handle); + } + } } + Some(crate::command::CmdResult::NeedPoint) } } } - fn needs_entity_pick(&self) -> bool { - matches!(self.step, DrawOrderStep::PickReference { .. }) - } - - fn on_entity_pick( - &mut self, - handle: acadrust::Handle, - _pt: glam::DVec3, - ) -> crate::command::CmdResult { - if handle.is_null() { - return crate::command::CmdResult::NeedPoint; - } - if let DrawOrderStep::PickReference { above } = self.step { - let opt = if above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); - let handles = std::mem::take(&mut self.selected); - crate::command::CmdResult::Relaunch(cmd, handles) - } else { - crate::command::CmdResult::NeedPoint + fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: glam::DVec3) -> crate::command::CmdResult { + if matches!(self.step, DrawOrderStep::PickReference { .. }) && !handle.is_null() + && !self.selected.contains(&handle) && !self.references.contains(&handle) { + self.references.push(handle); } + crate::command::CmdResult::NeedPoint } - fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { crate::command::CmdResult::NeedPoint } } +/// Insert the selected block next to the extremal reference in effective draw +/// order. Renumber the ordered siblings to avoid ties or saturated sort keys. +fn assign_relative_group_keys( + document: &acadrust::CadDocument, + block: acadrust::Handle, + selected: &[acadrust::Handle], + references: &[acadrust::Handle], + above: bool, +) -> Option> { + use acadrust::objects::ObjectType; + let overrides: std::collections::HashMap<_, _> = document.objects.values().find_map(|object| { + if let ObjectType::SortEntitiesTable(table) = object { + (table.block_owner_handle == block).then(|| table.entries().map(|entry| (entry.entity_handle, entry.sort_handle.value())).collect()) + } else { None } + }).unwrap_or_default(); + let mut ordered: Vec<_> = document.entities().filter(|entity| { + let owner = entity.common().owner_handle; + owner == block || owner.is_null() + }).map(|entity| entity.common().handle).collect(); + ordered.sort_by_key(|handle| (overrides.get(handle).copied().unwrap_or(handle.value()), handle.value())); + let selected_set: std::collections::HashSet<_> = selected.iter().copied().collect(); + let reference_set: std::collections::HashSet<_> = references.iter().copied().collect(); + let moved: Vec<_> = ordered.iter().copied().filter(|handle| selected_set.contains(handle)).collect(); + if moved.is_empty() { return None; } + ordered.retain(|handle| !selected_set.contains(handle)); + let indices: Vec<_> = ordered.iter().enumerate().filter_map(|(index, handle)| reference_set.contains(handle).then_some(index)).collect(); + let insertion = if above { indices.into_iter().max()? + 1 } else { indices.into_iter().min()? }; + ordered.splice(insertion..insertion, moved); + Some(ordered.into_iter().enumerate().map(|(index, handle)| (handle, index as u64 + 1)).collect()) +} /// Sort-key assignments sending `group` to the back of the active space. /// /// Normal case: the floor is the lowest effective sort key among non-moved From 7d88bc7c090b9e96ea11f342f22cf9a215e52704 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:28:31 +0300 Subject: [PATCH 51/99] Refresh property values after applying layer defaults --- src/app/commands/styleprops.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 0ac9061ae..9b060429c 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -736,6 +736,7 @@ impl OpenCADStudio { .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); + self.refresh_properties(); self.command_line.push_output(crate::tf!( "SETBYLAYER: reset {changed} entity/entities to ByLayer." ).as_ref()); From 4d90c9137244e7e31b7c659a31886f4a49f167b7 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:34:22 +0300 Subject: [PATCH 52/99] Validate wipeout boundary before asking to erase its source --- src/modules/draw/draw/wipeout.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index d9b689958..1ea1c4d26 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -30,6 +30,7 @@ pub struct WipeoutCommand { plane: WorkingPlane, selected_polyline: Option, frame_mode: i16, + picked_entity: Option, } #[derive(Clone, Copy, PartialEq)] @@ -50,6 +51,7 @@ impl WipeoutCommand { plane: WorkingPlane::default(), selected_polyline: None, frame_mode: frame_mode.clamp(0, 2), + picked_entity: None, } } @@ -61,6 +63,7 @@ impl WipeoutCommand { plane: WorkingPlane::default(), selected_polyline: None, frame_mode: 1, + picked_entity: None, } } @@ -73,6 +76,7 @@ impl WipeoutCommand { plane: WorkingPlane::default(), selected_polyline: None, frame_mode: 1, + picked_entity: None, } } @@ -238,6 +242,15 @@ impl CadCommand for WipeoutCommand { } fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + let Some(entity)=self.picked_entity.take().filter(|e|e.common().handle==handle) else { + return CmdResult::NeedPoint; + }; + if !matches!(entity,EntityType::LwPolyline(_)|EntityType::Polyline2D(_)) { + return CmdResult::NeedPoint; + } + if wipeout_from_polyline(&entity).is_none() { + return CmdResult::Measurement("WIPEOUT: select a closed planar polyline made of zero-width straight segments.".into()); + } if handle.is_null() { CmdResult::NeedPoint } else { @@ -247,6 +260,9 @@ impl CadCommand for WipeoutCommand { } } + fn inject_before_entity_pick(&self)->bool {self.mode==WipeoutMode::Polyline} + fn inject_picked_entity(&mut self,entity:EntityType){self.picked_entity=Some(entity);} + fn wants_text_input(&self) -> bool { matches!( self.mode, From 9ca2db82bf69b91f7c890754198be5fba6b4f3cd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:39:08 +0300 Subject: [PATCH 53/99] Add source-first joins with retained object identity --- src/app/command_driver.rs | 20 ++++++ src/app/commands/inquiry.rs | 5 +- src/command.rs | 2 + src/modules/draw/modify/join.rs | 114 +++++++++++++++++++++----------- 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..462f765f3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3263,6 +3263,26 @@ impl OpenCADStudio { self.command_line.push_info(&p); } } + CmdResult::JoinToSource { source, handles } => { + if self.reject_locked_edit(i, source) { return Task::none(); } + let joined = self.tabs[i].scene.document.get_entity(source).and_then(|entity| { + let candidates: Vec<_> = handles.iter().filter(|handle| **handle != source && !self.tabs[i].scene.is_layer_locked(**handle)) + .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).map(|entity| (*handle,entity))).collect(); + crate::modules::draw::modify::join::join_to_source(entity,&candidates) + }); + if let Some((replacement, consumed)) = joined { + self.push_undo_snapshot(i,"JOIN"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { *entity = replacement; } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.refresh_fill_model(source); + self.tabs[i].scene.bump_entities(&[(source,crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("JOIN: {} objects joined to source.",consumed.len())); + self.refresh_properties(); + } else { self.command_line.push_info("JOIN: no compatible objects joined to source."); } + self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + } CmdResult::JoinEntities(handles) => { if let Some(handle) = handles .iter() diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 08f920696..9f95c0ab1 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -218,7 +218,10 @@ impl OpenCADStudio { self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected)); return Some(task); } - let cmd = JoinCommand::new(); + let mut cmd = JoinCommand::new(); + if let Some(handle) = selected.first() { + if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { cmd = cmd.with_source(*handle, entity); } + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/command.rs b/src/command.rs index d69859462..35df1fbaa 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1475,6 +1475,8 @@ pub enum CmdResult { BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, /// Attempt to join the given entities into fewer merged entities. JoinEntities(Vec), + /// Join candidates into an explicitly selected source. + JoinToSource { source: Handle, handles: Vec }, /// Apply a polyline-edit operation to one entity; keep command active. PeditOp { handle: Handle, diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 9a101dea7..92c821c02 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -12,62 +12,98 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; // ── Command ──────────────────────────────────────────────────────────────── pub struct JoinCommand { + source: Option<(Handle, EntityType)>, + picked: Option, handles: Vec, - gathering: bool, } - impl JoinCommand { - pub fn new() -> Self { - Self { - handles: vec![], - gathering: true, - } + pub fn new() -> Self { Self { source: None, picked: None, handles: Vec::new() } } + pub fn with_source(mut self, handle: Handle, entity: EntityType) -> Self { + self.picked = Some(entity); self.on_entity_pick(handle, DVec3::ZERO); self } } - impl CadCommand for JoinCommand { - fn name(&self) -> &'static str { - "JOIN" - } - + fn name(&self) -> &'static str { "JOIN" } fn prompt(&self) -> String { - t!( - "JOIN Select objects to join (%{count} selected, Enter to apply):", - count = self.handles.len() - ) - .into_owned() - } - - fn is_selection_gathering(&self) -> bool { - self.gathering - } - - fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { - self.handles = handles; - CmdResult::NeedPoint - } - - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint - } - - fn on_enter(&mut self) -> CmdResult { - if self.handles.len() < 2 { - return CmdResult::Cancel; + match self.source.as_ref().map(|(_, entity)| entity) { + None => "JOIN Select source object:".into(), + Some(EntityType::Line(_)) => "JOIN Select lines to join to source (Enter to apply):".into(), + Some(EntityType::Arc(_)) => "JOIN Select arcs to join to source (Enter to apply):".into(), + _ => "JOIN Select objects to join to source (Enter to apply):".into(), } - self.gathering = false; - CmdResult::JoinEntities(self.handles.clone()) + } + fn needs_entity_pick(&self) -> bool { self.source.is_none() } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + fn on_entity_pick(&mut self, handle: Handle, _: DVec3) -> CmdResult { + if handle.is_null() { return CmdResult::NeedPoint; } + if let Some(entity) = self.picked.take() { + let supported = match &entity { + EntityType::Line(_) | EntityType::Arc(_) => true, + EntityType::LwPolyline(p) => !p.is_closed, + EntityType::Polyline2D(p) => !p.is_closed(), + _ => false, + }; + if supported { self.source = Some((handle, entity)); } + } + CmdResult::NeedPoint + } + fn is_selection_gathering(&self) -> bool { self.source.is_some() } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.handles = handles.into_iter().filter(|handle| self.source.as_ref().is_none_or(|(source,_)| handle != source)).collect(); CmdResult::NeedPoint + } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + let Some((source, _)) = self.source.as_ref() else { return CmdResult::Cancel; }; + if self.handles.is_empty() { return CmdResult::Cancel; } + CmdResult::JoinToSource { source: *source, handles: self.handles.clone() } } } -// ── Geometry ─────────────────────────────────────────────────────────────── +/// Join compatible candidates while preserving the source identity and style. +pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) -> Option<(EntityType, Vec)> { + let mut result = source.clone(); let mut consumed = Vec::new(); + loop { + let mut progress = false; + for (handle, candidate) in candidates { + if consumed.contains(handle) { continue; } + let next = match (&result, *candidate) { + (EntityType::Line(a), EntityType::Line(b)) => { + let point = |p: &Vector3| [p.x,p.y,p.z]; + cadkernel::space::source_join::join_collinear_lines([point(&a.start),point(&a.end)],[point(&b.start),point(&b.end)],JOIN_EPS).map(|span| { + let mut line = a.clone(); line.start = Vector3::new(span[0][0],span[0][1],span[0][2]); line.end = Vector3::new(span[1][0],span[1][1],span[1][2]); EntityType::Line(line) + }) + } + (EntityType::Arc(a), EntityType::Arc(b)) => { + let data = |a: &acadrust::entities::Arc| ([a.center.x,a.center.y,a.center.z],[a.normal.x,a.normal.y,a.normal.z],a.radius,[a.start_angle,a.end_angle]); + cadkernel::space::source_join::join_cocircular_arcs(data(a),data(b),JOIN_EPS).map(|span| { + if span[1]-span[0] >= std::f64::consts::TAU { + let mut circle = acadrust::entities::Circle::new(); circle.common = a.common.clone(); circle.center = a.center.clone(); circle.normal = a.normal.clone(); circle.radius = a.radius; circle.thickness = a.thickness; EntityType::Circle(circle) + } else { let mut arc = a.clone(); arc.start_angle = span[0]; arc.end_angle = span[1].rem_euclid(std::f64::consts::TAU); EntityType::Arc(arc) } + }) + } + (EntityType::LwPolyline(_) | EntityType::Polyline2D(_), _) => { + join_entities(&[(result.common().handle,&result),(*handle,*candidate)]).and_then(|(_,mut entities)| { + let entity = entities.pop()?; + if matches!(&entity,EntityType::Line(_)) { super::pedit::convert_to_polyline(&entity) } else { Some(entity) } + }) + } + _ => None, + }; + if let Some(mut entity) = next { + *entity.common_mut() = source.common().clone(); result = entity; consumed.push(*handle); progress = true; + } + } + if !progress { break; } + } + (!consumed.is_empty()).then_some((result,consumed)) +} /// Endpoint-match tolerance (model units). Segments split from a shared /// vertex meet exactly, so this only absorbs float noise. From 8e5f0955d2f585d0fc703a5f2db6685679596c48 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:43:42 +0300 Subject: [PATCH 54/99] Convert fitted polylines through the spline Object option --- src/app/commands/draw.rs | 2 +- src/modules/draw/draw/spline.rs | 81 +++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index cf848ff7b..693a228f7 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -881,7 +881,7 @@ impl OpenCADStudio { SplineCommand::control_vertices() } else { SplineCommand::new() - }; + }.with_document(&self.tabs[i].scene.document); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index ca51a07ec..3aff06f25 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -5,7 +5,8 @@ use crate::t; use acadrust::types::Vector3; -use acadrust::{EntityType, Spline}; +use acadrust::{CadDocument, EntityType, Handle, Spline}; +use std::collections::HashMap; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -31,6 +32,9 @@ pub struct SplineCommand { knot_parameterization: i32, begin_tangent: Vector3, end_tangent: Vector3, + choosing_objects: bool, + convertible: HashMap, + selected_objects: Vec, } impl SplineCommand { @@ -44,6 +48,9 @@ impl SplineCommand { knot_parameterization: 0, begin_tangent: Vector3::ZERO, end_tangent: Vector3::ZERO, + choosing_objects: false, + convertible: HashMap::new(), + selected_objects: Vec::new(), } } @@ -54,6 +61,13 @@ impl SplineCommand { } } + pub fn with_document(mut self, document: &CadDocument) -> Self { + self.convertible = document.entities().filter_map(|entity| { + spline_from_polyline(entity).map(|spline| (entity.common().handle, spline)) + }).collect(); + self + } + fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; @@ -70,6 +84,45 @@ impl SplineCommand { } } +fn control_spline(points: &[[f64; 3]], degree: usize, closed: bool) -> Option { + let curve = cadkernel::space::NurbsCurve3::from_control_polygon(degree, points, closed)?; + let mut spline = Spline { + degree: curve.degree() as i32, + control_points: curve.control_points().iter().map(|p| Vector3::new(p[0], p[1], p[2])).collect(), + knots: curve.knots().to_vec(), + weights: curve.weights().to_vec(), + ..Default::default() + }; + spline.flags.closed = closed; + spline.flags.periodic = closed; + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); + Some(spline) +} + +fn spline_from_polyline(entity: &EntityType) -> Option { + let (degree, points, closed, normal) = match entity { + EntityType::Polyline2D(poly) if poly.flags.is_spline_fit() => { + let degree = match poly.smooth_surface as i16 { 5 => 2, 6 => 3, _ => return None }; + let plane = crate::entities::curve::ocs_plane(poly.normal, poly.elevation); + let controls: Vec<_> = poly.vertices.iter().filter(|vertex| vertex.flags.bits() & 16 != 0) + .map(|vertex| plane.point_at([vertex.location.x, vertex.location.y])).collect(); + (degree, controls, poly.is_closed(), poly.normal) + } + EntityType::Polyline3D(poly) if poly.flags.spline_fit => { + let degree = match poly.smooth_type as i16 { 5 => 2, 6 => 3, _ => return None }; + let controls: Vec<_> = poly.vertices.iter().filter(|vertex| vertex.flags & 16 != 0) + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]).collect(); + (degree, controls, poly.is_closed(), poly.normal) + } + _ => return None, + }; + let mut spline = control_spline(&points, degree, closed)?; + spline.normal = normal; + spline.common = entity.common().clone(); + spline.common.handle = Handle::NULL; + Some(spline) +} + /// Store the chosen construction method directly in the persistent spline. fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { let mut points: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); @@ -141,6 +194,7 @@ impl CadCommand for SplineCommand { } fn prompt(&self) -> String { + if self.choosing_objects { return t!("Select spline-fit polylines:").to_string(); } if self.choosing_method { "SPLINE Choose creation method [Fit/Control vertices]:".into() } else if self.choosing_knots { @@ -159,6 +213,7 @@ impl CadCommand for SplineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + if self.choosing_objects { return Vec::new(); } if self.choosing_method { return vec![ CmdOption::new("Fit", "FIT"), @@ -170,7 +225,7 @@ impl CadCommand for SplineCommand { return vec![CmdOption::new("Chord", "CH"), CmdOption::new("Square root", "S"), CmdOption::new("Uniform", "U")]; } if self.pts.is_empty() { - let mut options = vec![CmdOption::new(t!("Method").as_ref(), "M")]; + let mut options = vec![CmdOption::new(t!("Method").as_ref(), "M"), CmdOption::new("Object", "O")]; if !self.control_vertices { options.push(CmdOption::new("Knots", "K")); } return options; } @@ -184,7 +239,7 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.choosing_method || self.choosing_knots || !pt.is_finite() { + if self.choosing_objects || self.choosing_method || self.choosing_knots || !pt.is_finite() { return CmdResult::NeedPoint; } if self.choosing_tangent { @@ -207,6 +262,14 @@ impl CadCommand for SplineCommand { } fn on_enter(&mut self) -> CmdResult { + if self.choosing_objects { + let replacements = self.selected_objects.iter().filter_map(|handle| { + self.convertible.get(handle).map(|spline| (*handle, vec![EntityType::Spline(spline.clone())])) + }).collect::>(); + return if replacements.is_empty() { CmdResult::Cancel } + else { CmdResult::ReplaceMany(replacements, Vec::new()) }; + } + if self.choosing_method || self.choosing_knots { self.choosing_method = false; self.choosing_knots = false; @@ -220,7 +283,13 @@ impl CadCommand for SplineCommand { } fn enter_accepts_default_start(&self) -> bool { - self.pts.is_empty() && !self.choosing_method + self.pts.is_empty() && !self.choosing_method && !self.choosing_objects + } + + fn is_selection_gathering(&self) -> bool { self.choosing_objects } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected_objects = handles.into_iter().filter(|handle| self.convertible.contains_key(handle)).collect(); + self.on_enter() } fn on_escape(&mut self) -> CmdResult { @@ -265,6 +334,10 @@ impl CadCommand for SplineCommand { self.choosing_tangent = true; Some(CmdResult::NeedPoint) } + "O" | "OBJECT" if self.pts.is_empty() => { + self.choosing_objects = true; + Some(CmdResult::NeedPoint) + } "M" | "METHOD" if self.pts.is_empty() => { self.choosing_method = true; Some(CmdResult::NeedPoint) From 7a9ffef5cf71f4c12925a1509d9e0c7c9fb39b07 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:43:58 +0300 Subject: [PATCH 55/99] Preserve periodic control-vertex spline seams --- src/modules/draw/draw/spline.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 7b05899ad..aa8597da6 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -52,20 +52,29 @@ impl SplineCommand { if self.pts.len() < 2 { return None; } - let mut spline = make_spline(&self.pts, closed, self.control_vertices); - if self.control_vertices { - let count = spline.control_points.len(); - let degree = self.degree.min(count - 1); - spline.degree = degree as i32; - spline.knots = (0..count + degree + 1).map(|index| { - if index <= degree { 0.0 } else if index >= count { 1.0 } - else { (index - degree) as f64 / (count - degree) as f64 } - }).collect(); - } + let spline = if self.control_vertices { + let points: Vec<_> = self.pts.iter().map(|point| point.to_array()).collect(); + control_spline(&points, self.degree.min(points.len() - 1), closed)? + } else { make_spline(&self.pts, closed, false) }; Some(EntityType::Spline(spline)) } } +fn control_spline(points: &[[f64; 3]], degree: usize, closed: bool) -> Option { + let curve = cadkernel::space::NurbsCurve3::from_control_polygon(degree, points, closed)?; + let mut spline = Spline { + degree: curve.degree() as i32, + control_points: curve.control_points().iter().map(|p| Vector3::new(p[0], p[1], p[2])).collect(), + knots: curve.knots().to_vec(), + weights: curve.weights().to_vec(), + ..Default::default() + }; + spline.flags.closed = closed; + spline.flags.periodic = closed; + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); + Some(spline) +} + /// Store the chosen construction method directly in the persistent spline. fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { let mut points: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); From 35bbb35f58173aa9b0696b613779eab31316f9d6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:45:27 +0300 Subject: [PATCH 56/99] Preserve joined polyline widths and add arc closure and spline joins --- src/modules/draw/modify/join.rs | 86 ++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 92c821c02..dab1904e4 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -34,10 +34,25 @@ impl CadCommand for JoinCommand { match self.source.as_ref().map(|(_, entity)| entity) { None => "JOIN Select source object:".into(), Some(EntityType::Line(_)) => "JOIN Select lines to join to source (Enter to apply):".into(), - Some(EntityType::Arc(_)) => "JOIN Select arcs to join to source (Enter to apply):".into(), + Some(EntityType::Arc(_)) => "JOIN Select arcs to join to source or [cLose] (Enter to apply):".into(), + Some(EntityType::Spline(_)) => "JOIN Select open curves to join to source (Enter to apply):".into(), _ => "JOIN Select objects to join to source (Enter to apply):".into(), } } + fn options(&self) -> Vec { + if matches!(&self.source,Some((_,EntityType::Arc(_)))) { vec![crate::command::CmdOption::new("Close","L")] } else {Vec::new()} + } + fn on_text_input(&mut self,text:&str) -> Option { + if matches!(text.trim().to_ascii_uppercase().as_str(),"L"|"CLOSE") { + if let Some((handle,EntityType::Arc(arc)))=&self.source { + let mut circle=acadrust::entities::Circle::new(); + circle.common=arc.common.clone();circle.common.handle=Handle::NULL; + circle.center=arc.center.clone();circle.normal=arc.normal.clone();circle.radius=arc.radius;circle.thickness=arc.thickness; + return Some(CmdResult::ReplaceMany(vec![(*handle,vec![EntityType::Circle(circle)])],Vec::new())); + } + } + None + } fn needs_entity_pick(&self) -> bool { self.source.is_none() } fn inject_before_entity_pick(&self) -> bool { true } fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } @@ -48,6 +63,7 @@ impl CadCommand for JoinCommand { EntityType::Line(_) | EntityType::Arc(_) => true, EntityType::LwPolyline(p) => !p.is_closed, EntityType::Polyline2D(p) => !p.is_closed(), + EntityType::Spline(p) => !p.flags.closed && !p.flags.periodic, _ => false, }; if supported { self.source = Some((handle, entity)); } @@ -88,6 +104,28 @@ pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) } else { let mut arc = a.clone(); arc.start_angle = span[0]; arc.end_angle = span[1].rem_euclid(std::f64::consts::TAU); EntityType::Arc(arc) } }) } + (EntityType::Spline(source), candidate) => { + let curve = |s:&acadrust::entities::Spline| { + if s.flags.closed || s.flags.periodic {return None;} + let weights=if s.weights.is_empty(){vec![1.0;s.control_points.len()]}else{s.weights.clone()}; + cadkernel::space::NurbsCurve3::new_strict(s.degree as usize,s.control_points.iter().map(|p|[p.x,p.y,p.z]).collect(),s.knots.clone(),weights) + }; + curve(source).and_then(|a| { + let b=match candidate { + EntityType::Spline(s)=>curve(s), + EntityType::Line(line)=>cadkernel::space::source_join::line_as_nurbs([[line.start.x,line.start.y,line.start.z],[line.end.x,line.end.y,line.end.z]],a.degree()), + _=>None, + }?; + let joined=cadkernel::space::source_join::join_nurbs_curves(&a,&b,JOIN_EPS)?; + let mut spline=source.clone(); + spline.control_points=joined.control_points().iter().map(|p|Vector3::new(p[0],p[1],p[2])).collect(); + spline.knots=joined.knots().to_vec();spline.weights=joined.weights().to_vec(); + spline.fit_points.clear();spline.flags.rational=true; + spline.flags.planar=cadkernel::space::are_coplanar(joined.control_points(),&[]); + spline.begin_tangent=Vector3::new(0.0,0.0,0.0);spline.end_tangent=Vector3::new(0.0,0.0,0.0); + Some(EntityType::Spline(spline)) + }) + } (EntityType::LwPolyline(_) | EntityType::Polyline2D(_), _) => { join_entities(&[(result.common().handle,&result),(*handle,*candidate)]).and_then(|(_,mut entities)| { let entity = entities.pop()?; @@ -117,12 +155,14 @@ struct Seg { a: DVec3, b: DVec3, bulge: f64, + widths: Option<(f64, f64)>, } impl Seg { fn flip(&mut self) { std::mem::swap(&mut self.a, &mut self.b); self.bulge = -self.bulge; + self.widths = self.widths.map(|(start,end)| (end,start)); } } @@ -160,6 +200,7 @@ fn segs_of(e: &EntityType) -> Option> { a: DVec3::new(l.start.x, l.start.y, l.start.z), b: DVec3::new(l.end.x, l.end.y, l.end.z), bulge: 0.0, + widths: None, }]), EntityType::Arc(arc) => { // The bulge below assumes the arc lies in a +Z plane; a tilted @@ -175,6 +216,7 @@ fn segs_of(e: &EntityType) -> Option> { a: DVec3::new(cx + r * sa.cos(), cy + r * sa.sin(), cz), b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz), bulge: (swept / 4.0).tan(), + widths: None, }]) } EntityType::LwPolyline(p) => { @@ -190,6 +232,7 @@ fn segs_of(e: &EntityType) -> Option> { a: DVec3::new(w[0].location.x, w[0].location.y, z), b: DVec3::new(w[1].location.x, w[1].location.y, z), bulge: w[0].bulge, + widths: Some(if p.constant_width != 0.0 { (p.constant_width,p.constant_width) } else { (w[0].start_width,w[0].end_width) }), }) .collect(), ) @@ -212,10 +255,12 @@ fn segs_of(e: &EntityType) -> Option> { curve .vertices .windows(2) - .map(|w| Seg { + .enumerate() + .map(|(index,w)| Seg { a: DVec3::new(w[0].position[0], w[0].position[1], z), b: DVec3::new(w[1].position[0], w[1].position[1], z), bulge: w[0].bulge, + widths: Some((if p.vertices[index].start_width == 0.0 {p.start_width} else {p.vertices[index].start_width}, if p.vertices[index].end_width == 0.0 {p.end_width} else {p.vertices[index].end_width})), }) .collect(), ) @@ -244,7 +289,13 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, let common = entities[0].1.common().clone(); let (thickness, normal) = extrusion(entities[0].1)?; - let (chain, closed) = stitch(segs)?; + let (mut chain, closed) = stitch(segs)?; + // Newly appended line/arc spans inherit the adjoining polyline width. + let mut width = chain.iter().find_map(|segment| segment.widths.map(|pair| pair.0)).unwrap_or(0.0); + for segment in &mut chain { + if let Some((_,end)) = segment.widths { width = end; } + else { segment.widths = Some((width,width)); } + } // Ordered vertices, each tagged with the bulge of the segment that // starts there. A closed chain reuses the first vertex as the wrap @@ -259,7 +310,7 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, let planar = verts.iter().all(|(p, _)| (p.z - z0).abs() <= JOIN_EPS); // An open run of collinear straight segments collapses back to one Line. - if !closed && !has_arc && is_collinear(&verts) { + if !closed && !has_arc && chain.iter().all(|segment| segment.widths == Some((0.0,0.0))) && !matches!(entities[0].1, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) && is_collinear(&verts) { let mut line = acadrust::entities::Line::new(); line.common = common; line.common.handle = Handle::NULL; @@ -276,13 +327,16 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, } let flipped = thickness != 0.0 && normal.z < 0.0; let lw_verts: Vec = verts - .iter() - .map(|(p, bulge)| { + .iter().enumerate() + .map(|(index,(p, bulge))| { let mut v = acadrust::entities::LwVertex::new(Vector2::new( if flipped { -p.x } else { p.x }, p.y, )); v.bulge = if flipped { -*bulge } else { *bulge }; + let (start,end) = chain.get(index).and_then(|segment| segment.widths) + .unwrap_or_else(|| {let width=chain.last().and_then(|segment| segment.widths).map_or(0.0,|pair|pair.1);(width,width)}); + v.start_width = start; v.end_width = end; v }) .collect(); @@ -296,6 +350,26 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, if flipped { pl.normal = Vector3::new(0.0, 0.0, -1.0); } + if let EntityType::Polyline2D(source) = entities[0].1 { + let mut polyline = source.clone(); + let input = crate::entities::curve::ocs_plane(pl.normal.clone(),pl.elevation); + let output = crate::entities::curve::ocs_plane(source.normal.clone(),source.elevation); + polyline.vertices = pl.vertices.iter().map(|vertex| { + let point = output.project(input.point_at([vertex.location.x,vertex.location.y]))?; + let mut result = acadrust::entities::polyline::Vertex2D::new(Vector3::new(point[0],point[1],source.elevation)); + result.bulge = if cadkernel::space::Vec3::from(input.normal()?).dot(cadkernel::space::Vec3::from(output.normal()?)) < 0.0 {-vertex.bulge} else {vertex.bulge}; result.start_width = vertex.start_width; result.end_width = vertex.end_width; + Some(result) + }).collect::>>()?; + polyline.start_width = 0.0; polyline.end_width = 0.0; + polyline.flags.set_closed(pl.is_closed); + return Some((handles,vec![EntityType::Polyline2D(polyline)])); + } + if let EntityType::LwPolyline(source) = entities[0].1 { + pl.plinegen = source.plinegen; + if source.constant_width != 0.0 && pl.vertices.iter().all(|vertex| vertex.start_width == source.constant_width && vertex.end_width == source.constant_width) { + pl.constant_width = source.constant_width; + } + } return Some((handles, vec![EntityType::LwPolyline(pl)])); } From 55454cbe6ba7203358d40380de4732cf02ed1fe9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:47:48 +0300 Subject: [PATCH 57/99] Preserve entity identity for one-to-one command edits --- src/app/command_driver.rs | 46 +++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..09682d5ab 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -927,6 +927,34 @@ impl OpenCADStudio { self.refresh_active_cmd_preview(i); } + /// Apply a command replacement without deleting a one-to-one entity edit. + /// Splits and entity-type conversions still allocate replacement identities. + fn replace_command_entity( + &mut self, + tab: usize, + handle: Handle, + mut entities: Vec, + ) -> Vec { + let same_type = entities.len() == 1 && self.tabs[tab].scene.document.get_entity(handle) + .is_some_and(|original| std::mem::discriminant(original) == std::mem::discriminant(&entities[0])); + let handles = if same_type { + let mut entity = entities.pop().expect("one replacement"); + entity.common_mut().handle = handle; + // Scene::update_entity also retains the live owning block and + // refreshes dependent render caches without erase notifications. + if self.tabs[tab].scene.update_entity(entity) { vec![handle] } else { Vec::new() } + } else { + self.tabs[tab].scene.erase_entities(&[handle]); + entities.into_iter().map(|entity| self.tabs[tab].scene.add_entity(entity)).collect() + }; + for &updated in &handles { + if matches!(self.tabs[tab].scene.document.get_entity(updated), Some(acadrust::EntityType::Dimension(_))) { + self.tabs[tab].scene.invalidate_dim_block_recorded(updated); + } + } + handles + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -2153,17 +2181,7 @@ impl OpenCADStudio { .is_some_and(|c| c.name() == "SS_CATCHMENT"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - self.tabs[i].scene.erase_entities(&[handle]); - for entity in entities { - let nh = self.tabs[i].scene.add_entity(entity); - // Drop a stale *D block on any replaced dimension (#181). - if matches!( - self.tabs[i].scene.document.get_entity(nh), - Some(acadrust::EntityType::Dimension(_)) - ) { - self.tabs[i].scene.invalidate_dim_block_recorded(nh); - } - } + self.replace_command_entity(i,handle,entities); } for entity in additions { self.tabs[i].scene.add_entity(entity); @@ -2189,11 +2207,7 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - self.tabs[i].scene.erase_entities(&[handle]); - let new_handles: Vec = entities - .into_iter() - .map(|entity| self.tabs[i].scene.add_entity(entity)) - .collect(); + let new_handles = self.replace_command_entity(i,handle,entities); if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_entity_replaced(handle, &new_handles); } From a04f13f84a86bfb494364928fec708553b1f16e9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:55:23 +0300 Subject: [PATCH 58/99] Initialize spline editing from a single unlocked preselected spline --- src/app/commands/inquiry.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 08f920696..7d98772cd 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -329,7 +329,17 @@ impl OpenCADStudio { "SPLINEDIT" => { use crate::modules::draw::modify::splinedit::SplineditCommand; - let cmd_obj = SplineditCommand::new(); + let mut cmd_obj = SplineditCommand::new(); + let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + if let [handle] = selected.as_slice() { + if let Some(entity @ acadrust::EntityType::Spline(_)) = + self.tabs[i].scene.document.get_entity(*handle).cloned() + { + if self.reject_locked_edit(i, *handle) { return Some(Task::none()); } + cmd_obj.inject_picked_entity(entity); + cmd_obj.on_entity_pick(*handle, glam::DVec3::ZERO); + } + } self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } From b42a9e773517c578955fc715a4d597b697d145b7 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:56:24 +0300 Subject: [PATCH 59/99] Accept two-point XLINE angles and offset distances --- src/modules/draw/draw/ray.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index d8a01d9d6..79405d4f0 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -128,6 +128,7 @@ pub struct XLineCommand { reference: Option<(DVec3, DVec3)>, picked: Option, offset: Option, + value_origin: Option, } #[derive(Clone, Copy)] @@ -151,6 +152,7 @@ impl XLineCommand { reference: None, picked: None, offset: None, + value_origin: None, } } @@ -188,6 +190,7 @@ impl CadCommand for XLineCommand { } fn prompt(&self) -> String { + if self.value_origin.is_some() { return "XLINE Specify second point:".into(); } match self.mode { XLineMode::Points if self.base.is_none() => "XLINE Specify a point or [Hor/Ver/Ang/Bisect/Offset]:", XLineMode::Angle => "XLINE Enter angle of xline <0> or [Reference]:", @@ -203,6 +206,7 @@ impl CadCommand for XLineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + if self.value_origin.is_some() { return vec![]; } match self.mode { XLineMode::Points if self.base.is_none() => vec![ CmdOption::new("Hor", "H"), CmdOption::new("Ver", "V"), @@ -217,6 +221,13 @@ impl CadCommand for XLineCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { if !pt.is_finite() { return CmdResult::NeedPoint; } + if matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance) { + if self.value_origin.is_none() { self.value_origin = Some(pt); } + else if let Some(value) = self.dyn_live_value(pt) { + return self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint); + } + return CmdResult::NeedPoint; + } if let Some((base, dir)) = self.geometry(pt) { let entity = XLineEnt::new( Vector3::new(base.x, base.y, base.z), @@ -241,6 +252,7 @@ impl CadCommand for XLineCommand { } fn on_enter(&mut self) -> CmdResult { + if self.value_origin.is_some() { return CmdResult::NeedPoint; } match self.mode { XLineMode::Angle => self.on_text_input("0").unwrap_or(CmdResult::NeedPoint), XLineMode::OffsetDistance => { @@ -264,6 +276,21 @@ impl CadCommand for XLineCommand { fn dyn_commit_as_text(&self) -> bool { matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance) } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let origin = self.value_origin?; + if !cursor.is_finite() { return None; } + match self.mode { + XLineMode::Angle => { + let delta = self.plane.vector_to_local(cursor - origin); + (delta.x.hypot(delta.y) > 1e-10).then(|| delta.y.atan2(delta.x).to_degrees()) + } + XLineMode::OffsetDistance => { + let distance = cadkernel::space::Vec3::from(cursor.to_array()).distance(origin.to_array().into()); + (distance.is_finite() && distance > 0.0).then_some(distance) + } + _ => None, + } + } fn dyn_auto_sign_angle(&self) -> bool { false } fn needs_entity_pick(&self) -> bool { matches!(self.mode, XLineMode::AngleReference | XLineMode::OffsetPick) @@ -303,6 +330,7 @@ impl CadCommand for XLineCommand { } XLineMode::Angle => { if key == "R" || key == "REFERENCE" { + if self.value_origin.is_some() { return None; } self.mode = XLineMode::AngleReference; } else { let angle: f64 = key.parse().ok().filter(|v: &f64| v.is_finite())?; @@ -312,13 +340,17 @@ impl CadCommand for XLineCommand { } } XLineMode::OffsetDistance => { - self.offset = if key == "T" || key == "THROUGH" { None } else { + self.offset = if key == "T" || key == "THROUGH" { + if self.value_origin.is_some() { return None; } + None + } else { Some(key.parse::().ok().filter(|v| v.is_finite() && *v > 0.0)?) }; self.mode = XLineMode::OffsetPick; } _ => return None, } + self.value_origin = None; Some(CmdResult::NeedPoint) } From 25ac594d85c6792b074faa3b96de4920a664a6f9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:03:58 +0300 Subject: [PATCH 60/99] Reverse complete helix geometry and preserve top radius semantics --- src/entities/helix.rs | 42 +++++++++++++++++++++++------- src/modules/draw/draw/helix.rs | 2 +- src/modules/draw/modify/reverse.rs | 3 ++- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/entities/helix.rs b/src/entities/helix.rs index aa753c6e3..ef50e5560 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -52,10 +52,13 @@ fn spline_curve(spline: &Spline) -> Option { fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> { let base = point(helix.axis_base_point); - let axis = point(helix.axis_vector).normalize()?; - let radial = point(helix.start_point) - base; - let start_direction = (radial - axis * radial.dot(axis)).normalize()?; - Some((base, axis, start_direction)) + let tangent = spline_curve(&helix.spline).map(|curve| curve.tangent_at(0.0)).unwrap_or([0.0;3]); + let (axis, radial, _) = HelixCurve::frame_from_points(base.to_array(),point(helix.axis_vector).to_array(),point(helix.start_point).to_array(),tangent)?; + Some((base,Vec3::from(axis),Vec3::from(radial))) +} + +fn base_radius(helix: &Helix) -> Option { + radius_from_axis(helix, point(helix.start_point)) } fn radius_from_axis(helix: &Helix, value: Vec3) -> Option { @@ -77,7 +80,7 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { base_center: base.to_array(), axis_direction: axis.to_array(), start_direction: start_direction.to_array(), - base_radius: helix.radius, + base_radius: base_radius(helix)?, top_radius, height: helix.turns * helix.turn_height, turns: helix.turns, @@ -89,6 +92,27 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { }) } +/// Reverse generating parameters together with the exact stored spline. +pub(crate) fn reversed(helix: &Helix) -> Option { + let original_curve = spline_curve(&helix.spline)?; + let parameters = kernel_curve(helix,top_radius(helix))?.reversed()?; + let mut result = helix.clone(); + result.axis_base_point = vector(Vec3::from(parameters.base_center)); + result.axis_vector = vector(Vec3::from(parameters.axis_direction)); + result.start_point = vector(Vec3::from(original_curve.point_at(1.0))); + result.radius = parameters.top_radius; + // Reverse the evaluated curve itself instead of fitting it again. + let reversed_curve=original_curve.reversed()?; + result.spline.control_points=reversed_curve.control_points().iter().map(|point|Vector3::new(point[0],point[1],point[2])).collect(); + result.spline.fit_points.reverse(); + result.spline.weights=if helix.spline.weights.is_empty(){Vec::new()}else{reversed_curve.weights().to_vec()}; + result.spline.knots=reversed_curve.knots().to_vec(); + let begin=helix.spline.begin_tangent;let end=helix.spline.end_tangent; + result.spline.begin_tangent=Vector3::new(-end.x,-end.y,-end.z); + result.spline.end_tangent=Vector3::new(-begin.x,-begin.y,-begin.z); + Some(result) +} + fn projected_direction(direction: Vec3, axis: Vec3) -> Option { (direction - axis * direction.dot(axis)).normalize() } @@ -111,6 +135,7 @@ fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { let Some(nurbs) = curve.nurbs() else { return false; }; + helix.radius = top_radius; helix.spline.degree = nurbs.degree() as i32; helix.spline.knots = nurbs.knots().to_vec(); helix.spline.control_points = nurbs @@ -194,7 +219,7 @@ fn properties(helix: &Helix) -> Vec { edit(t!("Height").as_ref(), "height", height), edit(t!("Turns").as_ref(), "turns", helix.turns), edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height), - edit(t!("Base radius").as_ref(), "base_radius", helix.radius), + edit(t!("Base radius").as_ref(), "base_radius", base_radius(helix).unwrap_or(0.0)), edit(t!("Top radius").as_ref(), "top_radius", top_radius), choice(t!("Twist").as_ref(), "twist", &twist, &twist_options), ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)), @@ -284,7 +309,6 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { let Some((base, _, start_direction)) = axis_frame(helix) else { return; }; - helix.radius = number; helix.start_point = vector(base + start_direction * number); } "top_radius" if number >= 0.0 => { @@ -303,6 +327,7 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { let original = helix.clone(); let top = top_radius(helix); + let Some(base_radius) = base_radius(helix) else { return; }; let Some((base, axis, start_direction)) = axis_frame(helix) else { return; }; @@ -334,7 +359,6 @@ fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { if radius <= EPSILON { return; } - helix.radius = radius; helix.start_point = vector(base + radial); } (2, GripApply::Absolute(position)) => { @@ -362,7 +386,7 @@ fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { *helix = original; return; }; - helix.start_point = vector(base + start_direction * helix.radius); + helix.start_point = vector(base + start_direction * base_radius); } (3, GripApply::Absolute(position)) => { let position = kernel(position); diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs index fbeb2ca63..c081db6eb 100644 --- a/src/modules/draw/draw/helix.rs +++ b/src/modules/draw/draw/helix.rs @@ -134,7 +134,7 @@ impl HelixCommand { let start = self.center + start_direction * curve.base_radius; helix.start_point = Vector3::new(start.x, start.y, start.z); helix.axis_vector = Vector3::new(axis.x, axis.y, axis.z); - helix.radius = curve.base_radius; + helix.radius = curve.top_radius; helix.turns = curve.turns; helix.turn_height = height / curve.turns; helix.handedness = self.counter_clockwise; diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index 4e2cfdffd..75976031c 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -76,6 +76,7 @@ impl ReverseCommand { Some(EntityType::Polyline3D(out)) } EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))), + EntityType::Helix(helix) => crate::entities::helix::reversed(helix).map(EntityType::Helix), _ => None, } } @@ -199,7 +200,7 @@ impl CadCommand for ReverseCommand { } fn prompt(&self) -> String { - t!("REVERSE Select line, polyline or spline to reverse:").into_owned() + t!("REVERSE Select line, polyline, spline or helix to reverse:").into_owned() } fn needs_entity_pick(&self) -> bool { From ebef847870c7de2568c7a23854a6f9a2583f753d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:04:30 +0300 Subject: [PATCH 61/99] Edit individual polyline segment widths at the selected vertex --- src/modules/draw/modify/pedit.rs | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 8f6c3214a..0b0d69878 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -39,6 +39,8 @@ enum Mode { PolyVertex(usize), PolyMove(usize), PolyInsert(usize), + PolyWidthStart(usize, f64), + PolyWidthEnd(usize, f64), /// Join: gathering additional segments; Enter merges. JoinGather(Vec), /// Polygon-mesh vertex navigation uses the mesh's row-major control net. @@ -141,6 +143,15 @@ impl PeditCommand { } } + fn vertex_width(&self, index: usize) -> f64 { + let entity = self.target.and_then(|handle| self.entities.get(&handle.value())); + match entity { + Some(EntityType::LwPolyline(p)) => if p.constant_width != 0.0 { p.constant_width } else { p.vertices.get(index).map_or(0.0, |v| v.start_width) }, + Some(EntityType::Polyline2D(p)) => p.vertices.get(index).map_or(p.start_width, |v| if v.start_width == 0.0 { p.start_width } else { v.start_width }), + _ => 0.0, + } + } + fn vertex_count(&self) -> usize { self.target.and_then(|handle| self.entities.get(&handle.value())).map_or(0, |entity| match entity { EntityType::LwPolyline(polyline) => polyline.vertices.len(), @@ -218,8 +229,10 @@ impl CadCommand for PeditCommand { vertex = index + 1 ) .into_owned(), - Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Insert/Move/eXit] :", index + 1), + Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Insert/Move/Width/eXit] :", index + 1), Mode::PolyMove(index) => format!("PEDIT Specify new location for vertex {}:", index + 1), + Mode::PolyWidthStart(_, width) => format!("PEDIT Specify starting width for next segment <{width}>:"), + Mode::PolyWidthEnd(_, width) => format!("PEDIT Specify ending width for next segment <{width}>:"), Mode::PolyInsert(index) => format!("PEDIT Specify location after vertex {}:", index + 1), Mode::Options => t!("PEDIT Enter option:").into_owned(), } @@ -279,7 +292,7 @@ impl CadCommand for PeditCommand { vec![CmdOption::new(t!("Yes").as_ref(), "Y"), CmdOption::new(t!("No").as_ref(), "N")] } Mode::JoinGather(_) => vec![CmdOption::enter(t!("Join").as_ref())], - Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Exit", "X")], + Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Width", "W"), CmdOption::new("Exit", "X")], Mode::AwaitLinetype => vec![CmdOption::new("On", "ON"), CmdOption::new("Off", "OFF")], Mode::MeshVertexMove(_) => vec![], _ => vec![], @@ -379,6 +392,7 @@ impl CadCommand for PeditCommand { let up = text.trim().to_uppercase(); let mesh_size = self.mesh_size(); let vertex_count = self.vertex_count(); + let current_width = if let Mode::PolyVertex(index) = self.mode { self.vertex_width(index) } else { 0.0 }; let result = match &mut self.mode { Mode::PickTarget => { if matches!(up.as_str(), "M" | "MULTIPLE") { self.mode = Mode::MultipleGather; Some(CmdResult::NeedPoint) } else { None } @@ -432,6 +446,19 @@ impl CadCommand for PeditCommand { self.mode = Mode::Options; Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::SetLinetypeGeneration(enabled) }) } + Mode::PolyWidthStart(index, _) => { + let width = text.trim().parse::().ok()?; + if !width.is_finite() || width < 0.0 { return Some(CmdResult::NeedPoint); } + self.mode = Mode::PolyWidthEnd(*index, width); + Some(CmdResult::NeedPoint) + } + Mode::PolyWidthEnd(index, start) => { + let end = text.trim().parse::().ok()?; + if !end.is_finite() || end < 0.0 { return Some(CmdResult::NeedPoint); } + let (index, start) = (*index, *start); + self.mode = Mode::PolyVertex(index); + Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::SetVertexWidth { index, start, end } }) + } Mode::JoinGather(_) => None, Mode::PolyMove(_) | Mode::PolyInsert(_) => None, Mode::PolyVertex(index) => { @@ -440,6 +467,7 @@ impl CadCommand for PeditCommand { "P" | "PREVIOUS" => { *index = index.saturating_sub(1); } "I" | "INSERT" => self.mode = Mode::PolyInsert(*index), "M" | "MOVE" => self.mode = Mode::PolyMove(*index), + "W" | "WIDTH" => self.mode = Mode::PolyWidthStart(*index, current_width), "X" | "EXIT" => self.mode = Mode::Options, _ => {} } @@ -643,6 +671,7 @@ impl CadCommand for PeditCommand { CmdResult::NeedPoint } Mode::MultipleConvert => self.on_text_input("Y").unwrap_or(CmdResult::NeedPoint), + Mode::PolyWidthStart(_, width) | Mode::PolyWidthEnd(_, width) => { let value = width.to_string(); self.on_text_input(&value).unwrap_or(CmdResult::NeedPoint) } Mode::PolyVertex(_) => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), Mode::PolyMove(_) | Mode::PolyInsert(_) => { self.mode = Mode::Options; CmdResult::NeedPoint } Mode::AwaitLinetype => { self.mode = Mode::Options; CmdResult::NeedPoint } @@ -681,6 +710,7 @@ pub enum PeditOp { Multiple(Vec, Box), SetClosed(bool), SetWidth(f64), + SetVertexWidth { index: usize, start: f64, end: f64 }, SetLinetypeGeneration(bool), EditVertex { index: usize, point: DVec3, insert: bool }, Reverse, @@ -704,6 +734,29 @@ pub enum PeditOp { pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { PeditOp::Multiple(_, _) => false, + PeditOp::SetVertexWidth { index, start, end } => { + if !start.is_finite() || !end.is_finite() || *start < 0.0 || *end < 0.0 { return false; } + match entity { + EntityType::LwPolyline(p) => { + if *index >= p.vertices.len() { return false; } + if p.constant_width != 0.0 { + for v in &mut p.vertices { v.start_width = p.constant_width; v.end_width = p.constant_width; } + p.constant_width = 0.0; + } + p.vertices[*index].start_width = *start; p.vertices[*index].end_width = *end; true + } + EntityType::Polyline2D(p) => { + if *index >= p.vertices.len() { return false; } + for v in &mut p.vertices { + if v.start_width == 0.0 { v.start_width = p.start_width; } + if v.end_width == 0.0 { v.end_width = p.end_width; } + } + p.start_width = 0.0; p.end_width = 0.0; + p.vertices[*index].start_width = *start; p.vertices[*index].end_width = *end; true + } + _ => false, + } + } PeditOp::EditVertex { index, point, insert } => { if !point.is_finite() { return false; } match entity { From abab89e20693ddd5cfc3691dd45dc7b282eaeab8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:05:10 +0300 Subject: [PATCH 62/99] Delete spline control vertices through the Refine submenu --- src/modules/draw/modify/splinedit.rs | 31 ++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 828980f97..0007d0402 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -34,6 +34,7 @@ enum Step { Options, Refine, Add, + Delete, Elevate, Move { index: usize, refine: bool }, Weight { index: usize }, @@ -89,8 +90,9 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), Step::Options => crate::t!("SPLINEDIT [Close/Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), - Step::Refine => crate::t!("SPLINEDIT [Add/Elevate order/Move/Weight/eXit] :").into_owned(), + Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), + Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)), Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] :", index + 1), Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(), @@ -101,7 +103,7 @@ impl CadCommand for SplineditCommand { use crate::command::CmdOption; match self.step { Step::Options => vec![CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], - Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], + Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], _ => Vec::new(), } @@ -125,7 +127,7 @@ impl CadCommand for SplineditCommand { } } } - fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Move { .. } | Step::SelectVertex { .. }) } + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { let upper = text.trim().to_uppercase(); if upper.is_empty() { return Some(self.on_enter()); } @@ -151,6 +153,7 @@ impl CadCommand for SplineditCommand { }, Step::Refine => match upper.as_str() { "A" | "ADD" => self.step = Step::Add, + "D" | "DELETE" => self.step = Step::Delete, "E" | "ELEVATE" => self.step = Step::Elevate, "M" | "MOVE" => self.step = Step::Move { index: 0, refine: true }, "W" | "WEIGHT" => self.step = Step::Weight { index: 0 }, @@ -204,6 +207,26 @@ impl CadCommand for SplineditCommand { } CmdResult::NeedPoint } + Step::Delete => { + let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; + let Some(index) = source.control_points.iter().enumerate().min_by(|(_, a), (_, b)| { + let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); + distance(a).total_cmp(&distance(b)) + }).map(|(index, _)| index) else { return CmdResult::NeedPoint; }; + let controls = source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } + else { source.weights.clone() }; + let curve = cadkernel::space::NurbsCurve3::new_strict(source.degree as usize, controls, + source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic)); + let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index)) else { return CmdResult::NeedPoint; }; + let mut spline = source.clone(); + spline.degree = curve.degree() as i32; + spline.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + spline.weights = curve.weights().to_vec(); + spline.knots = curve.knots().to_vec(); + spline.fit_points.clear(); + self.replace(spline) + } Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), Step::Move { index, .. } => { let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; }; @@ -219,7 +242,7 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline | Step::Options => CmdResult::Cancel, Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } - Step::Add | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } + Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { let degree = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); self.on_text_input(°ree.to_string()).unwrap_or(CmdResult::NeedPoint) From 36b799877321a521fc31c6797e428425d8f1d3ba Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:05:52 +0300 Subject: [PATCH 63/99] Preserve unsupported closed spline deletion state --- src/modules/draw/modify/splinedit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 0007d0402..663adf959 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -217,7 +217,7 @@ impl CadCommand for SplineditCommand { let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; let curve = cadkernel::space::NurbsCurve3::new_strict(source.degree as usize, controls, - source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic)); + source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic || source.flags.closed)); let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index)) else { return CmdResult::NeedPoint; }; let mut spline = source.clone(); spline.degree = curve.degree() as i32; From 12c661d11867214aae1be74d12eaafe26d4b90a9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:07:49 +0300 Subject: [PATCH 64/99] Refresh spline planarity after control vertex deletion --- src/modules/draw/modify/splinedit.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 663adf959..e17c0da1a 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -225,6 +225,7 @@ impl CadCommand for SplineditCommand { spline.weights = curve.weights().to_vec(); spline.knots = curve.knots().to_vec(); spline.fit_points.clear(); + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); self.replace(spline) } Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), From 8f00e00c5a29fe0d00c8bf6709b425b7fcbd4b52 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:10:56 +0300 Subject: [PATCH 65/99] Clean up duplicate and overlapping circular arcs --- src/app/commands/styleprops.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 068138552..0008f925c 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -721,6 +721,26 @@ impl OpenCADStudio { if left==right { removed.insert(candidates[b].0);progress=true;continue; } + if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) { + use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union}; + if l.common!=r.common || l.thickness!=r.thickness {continue;} + let arc=|v:&acadrust::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle}; + let Some(union)=circular_arc_union(arc(l),arc(r),tolerance) else {continue;}; + let allowed=match union.kind {ArcUnionKind::Duplicate=>true,ArcUnionKind::Overlap=>overlap,ArcUnionKind::EndToEnd=>end_to_end}; + if !allowed {continue;} + if let acadrust::EntityType::Arc(source)=&candidates[a].1 { + let replacement=if union.full_circle { + let mut circle=acadrust::entities::Circle::new(); + circle.common=source.common.clone();circle.center=source.center.clone();circle.normal=source.normal.clone();circle.radius=source.radius;circle.thickness=source.thickness; + acadrust::EntityType::Circle(circle) + } else { + let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;acadrust::EntityType::Arc(arc) + }; + candidates[a].1=replacement; + } + changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + continue; + } let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right) else {continue;}; if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;} From 42439c2e501c2ca5bbef49f4d9049d65897fcccf Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:13:09 +0300 Subject: [PATCH 66/99] Pick spline control vertices with the configured screen aperture --- src/app/command_driver.rs | 28 ++++++++++++++++++++ src/app/update/viewport.rs | 2 ++ src/command.rs | 15 +++++++++++ src/modules/draw/modify/splinedit.rs | 39 +++++++++++++++++++++------- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..8b80557e9 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -312,6 +312,33 @@ impl OpenCADStudio { true } + /// Refresh screen-space feature picking immediately before point dispatch. + pub(super) fn refresh_command_point_pick_context(&mut self, i: usize) { + if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.wants_point_pick_context()) + { + let scene = &self.tabs[i].scene; + let size = scene.selection.borrow().vp_size; + let edit = scene.viewport_edit_frame(size); + let tile = edit.as_ref().map(|(_, rect)| *rect) + .unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1)); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: tile.width, height: tile.height }; + let context = if bounds.width > 0.0 && bounds.height > 0.0 { + let (view, eye) = if let Some((camera, _)) = edit { + (camera.view_proj_rte(bounds), camera.eye()) + } else { + let camera = scene.camera.borrow(); + (camera.view_proj_rte(bounds), camera.eye()) + }; + Some(crate::command::PointPickContext { view, eye, bounds, + aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box) }) + } else { None }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_point_pick_context(context); + } + } + } + /// Drive the active command's step machine with one [`StepInput`], then /// apply the result. This is the single entry point every input source /// (command line, headless, dynamic input, plugin API, viewport) funnels @@ -414,6 +441,7 @@ impl OpenCADStudio { command.inject_selection_entities(entities); } } + if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 606ffa871..8dee50e81 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3887,6 +3887,7 @@ impl OpenCADStudio { } else if self.tabs[i].active_cmd.as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { + self.refresh_command_point_pick_context(i); self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd @@ -3963,6 +3964,7 @@ impl OpenCADStudio { // (LINE tangent to two circles, which needs both). When // it does, sync last_point to the command's resolved // anchor since it replaced the picked coordinate. + self.refresh_command_point_pick_context(i); let handled = self.tabs[i] .active_cmd .as_mut() diff --git a/src/command.rs b/src/command.rs index d69859462..177d25894 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1939,6 +1939,15 @@ impl InputKind { } } +/// Current screen projection and configured aperture for point-feature picking. +#[derive(Clone, Copy)] +pub struct PointPickContext { + pub view: glam::Mat4, + pub eye: DVec3, + pub bounds: iced::Rectangle, + pub aperture_px: f32, +} + pub trait CadCommand: Send { /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. @@ -2006,6 +2015,12 @@ pub trait CadCommand: Send { /// Called when the user left-clicks in the viewport (point pick). fn on_point(&mut self, pt: DVec3) -> CmdResult; + /// Opt in only while a point input selects an existing point feature. + fn wants_point_pick_context(&self) -> bool { false } + + /// Refreshed for each point input so zoom and viewport changes are reflected. + fn set_point_pick_context(&mut self, _context: Option) {} + /// Called when the user presses Enter (finalize / next option). fn on_enter(&mut self) -> CmdResult; diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index e17c0da1a..379620e27 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -47,11 +47,31 @@ pub struct SplineditCommand { spline: Option, pending: Option, history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, + pick_context: Option, } impl SplineditCommand { pub fn new() -> Self { - Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new() } + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None } + } + + fn picked_vertex(&self, point: DVec3) -> Option { + let context = self.pick_context?; + let project = |point: DVec3| { + let clip = context.view * (point - context.eye).as_vec3().extend(1.0); + if !clip.is_finite() || clip.w <= 0.0 { return None; } + let screen = crate::scene::pick::hit_test::world_to_screen( + point, context.view, context.eye, context.bounds); + (screen.x.is_finite() && screen.y.is_finite() + && screen.x >= 0.0 && screen.x <= context.bounds.width + && screen.y >= 0.0 && screen.y <= context.bounds.height).then_some(screen) + }; + let cursor = project(point)?; + self.spline.as_ref()?.control_points.iter().enumerate().filter_map(|(index, vertex)| { + let screen = project(DVec3::new(vertex.x, vertex.y, vertex.z))?; + let distance = (screen.x - cursor.x).hypot(screen.y - cursor.y); + (distance.is_finite() && distance <= context.aperture_px).then_some((index, distance)) + }).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(index, _)| index) } fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { @@ -194,25 +214,24 @@ impl CadCommand for SplineditCommand { } Some(CmdResult::NeedPoint) } + fn wants_point_pick_context(&self) -> bool { + matches!(self.step, Step::Delete | Step::SelectVertex { .. }) + } + fn set_point_pick_context(&mut self, context: Option) { + self.pick_context = context; + } fn on_point(&mut self, point: DVec3) -> CmdResult { if !point.is_finite() { return CmdResult::NeedPoint; } match self.step { Step::SelectVertex { weight, refine } => { - if let Some(index) = self.spline.as_ref().and_then(|spline| spline.control_points.iter().enumerate() - .min_by(|(_, a), (_, b)| { - let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); - distance(a).total_cmp(&distance(b)) - }).map(|(index, _)| index)) { + if let Some(index) = self.picked_vertex(point) { self.step = if weight { Step::Weight { index } } else { Step::Move { index, refine } }; } CmdResult::NeedPoint } Step::Delete => { let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; - let Some(index) = source.control_points.iter().enumerate().min_by(|(_, a), (_, b)| { - let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); - distance(a).total_cmp(&distance(b)) - }).map(|(index, _)| index) else { return CmdResult::NeedPoint; }; + let Some(index) = self.picked_vertex(point) else { return CmdResult::NeedPoint; }; let controls = source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(); let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; From 3ab2dcf36c96bb8303d8a4d380313a38f246f348 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:21:59 +0300 Subject: [PATCH 67/99] Persist draw-order table ownership and reserved handles --- src/app/commands/view.rs | 102 ++++++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 88c7dc5e1..a54c82c25 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1,5 +1,63 @@ use super::*; +// Sort tables belong to the block's extension dictionary. Reserve real handles +// and reconnect older unattached tables without losing their existing entries. +fn ensure_draw_order_table( + doc: &mut acadrust::CadDocument, + block: acadrust::Handle, +) -> acadrust::Handle { + use acadrust::objects::{Dictionary, ObjectType, SortEntitiesTable}; + + // Older commands inserted objects using the unreserved next-handle value. + // Repair the allocator floor before creating anything beside those objects. + if let Some(maximum) = doc.objects.keys().map(|handle| handle.value()).max() { + doc.header.handle_seed = doc.header.handle_seed.max(maximum.saturating_add(1)); + } + let dictionary = doc.extension_dictionary_handle(block).filter(|handle| { + matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_))) + }); + let named_table = dictionary.and_then(|handle| match doc.objects.get(&handle) { + Some(ObjectType::Dictionary(value)) => value.get(SortEntitiesTable::DICTIONARY_KEY), + _ => None, + }).filter(|handle| matches!(doc.objects.get(handle), + Some(ObjectType::SortEntitiesTable(table)) if table.block_owner_handle == block)); + let existing = named_table.or_else(|| doc.objects.iter().find_map(|(handle, object)| { + match object { + ObjectType::SortEntitiesTable(table) if table.block_owner_handle == block => Some(*handle), + _ => None, + } + })); + let dictionary = dictionary.unwrap_or_else(|| { + let handle = doc.allocate_handle(); + let mut value = Dictionary::new(); + value.handle = handle; + value.owner = block; + value.hard_owner = true; + doc.objects.insert(handle, ObjectType::Dictionary(value)); + handle + }); + doc.xdic_by_handle.insert(block, dictionary); + let handle = existing.unwrap_or_else(|| { + let handle = doc.allocate_handle(); + let mut table = SortEntitiesTable::for_block(block); + table.handle = handle; + doc.objects.insert(handle, ObjectType::SortEntitiesTable(table)); + handle + }); + if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&handle) { + table.owner_handle = dictionary; + } + if let Some(ObjectType::Dictionary(value)) = doc.objects.get_mut(&dictionary) { + if let Some((_, entry)) = value.entries.iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case(SortEntitiesTable::DICTIONARY_KEY)) { + *entry = handle; + } else { + value.add_entry(SortEntitiesTable::DICTIONARY_KEY, handle); + } + } + handle +} + impl OpenCADStudio { pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -817,7 +875,7 @@ impl OpenCADStudio { // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order. "HATCHTOBACK" => { - use acadrust::objects::{ObjectType, SortEntitiesTable}; + use acadrust::objects::ObjectType; let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let doc_ref = &self.tabs[i].scene.document; @@ -894,24 +952,9 @@ impl OpenCADStudio { &locked_layers, ); - // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). - let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); - - // 5. Update or insert SortEntitiesTable directly. - let table_before = existing_table_handle - .and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned()); - let th = existing_table_handle.unwrap_or_else(|| { - let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle()); - let mut table = SortEntitiesTable::for_block(block_handle); - table.handle = nh; - self.tabs[i] - .scene - .document - .objects - .insert(nh, ObjectType::SortEntitiesTable(table)); - nh - }); - self.tabs[i].scene.record_undo_object_before(th, table_before); + // Dictionary ownership and handle allocation are part of this undo step. + let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), false); + let th = ensure_draw_order_table(&mut self.tabs[i].scene.document, block_handle); if let Some(ObjectType::SortEntitiesTable(table)) = self.tabs[i].scene.document.objects.get_mut(&th) @@ -984,7 +1027,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("DRAWORDER ") => { - use acadrust::objects::{ObjectType, SortEntitiesTable}; + use acadrust::objects::ObjectType; let parts: Vec<&str> = cmd.split_whitespace().collect(); let option = parts.get(1).unwrap_or(&"").to_uppercase(); let i = self.active_tab; @@ -1098,24 +1141,7 @@ impl OpenCADStudio { }; let doc = &mut self.tabs[i].scene.document; - let table_handle = doc.objects.iter().find_map(|(h, obj)| { - if let ObjectType::SortEntitiesTable(t) = obj { - if t.block_owner_handle == block_handle { - Some(*h) - } else { - None - } - } else { - None - } - }); - let th = table_handle.unwrap_or_else(|| { - let nh = acadrust::Handle::new(doc.next_handle()); - let mut table = SortEntitiesTable::for_block(block_handle); - table.handle = nh; - doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); - nh - }); + let th = ensure_draw_order_table(doc, block_handle); if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { if let Some(assignments) = &relative_assignments { From 189424e3d4e3dc74223b54f05c3cc50ae1507f67 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:27:59 +0300 Subject: [PATCH 68/99] Remove arcs covered by selected circles when overlap cleanup is enabled --- src/app/commands/styleprops.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 0008f925c..318a20d80 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -721,6 +721,25 @@ impl OpenCADStudio { if left==right { removed.insert(candidates[b].0);progress=true;continue; } + if overlap { + let contained = |circle: &acadrust::entities::Circle, arc: &acadrust::entities::Arc| { + circle.common == arc.common && circle.thickness == arc.thickness + && cadkernel::space::arc_union::circle_contains_arc( + [circle.center.x,circle.center.y,circle.center.z], + [circle.normal.x,circle.normal.y,circle.normal.z],circle.radius, + cadkernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z], + normal:[arc.normal.x,arc.normal.y,arc.normal.z],radius:arc.radius,start:arc.start_angle,end:arc.end_angle }) + }; + match (&left,&right) { + (acadrust::EntityType::Circle(circle),acadrust::EntityType::Arc(arc)) if contained(circle,arc) => { + removed.insert(candidates[b].0);progress=true;continue; + } + (acadrust::EntityType::Arc(arc),acadrust::EntityType::Circle(circle)) if contained(circle,arc) => { + removed.insert(candidates[a].0);progress=true;break; + } + _ => {}, + } + } if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) { use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union}; if l.common!=r.common || l.thickness!=r.thickness {continue;} From e3b1f5719ce957c3f89ad472a63ae578eea3504c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:31:34 +0300 Subject: [PATCH 69/99] Edit polyline vertex ranges with deferred break and straighten --- src/app/command_driver.rs | 18 +++++++++ src/modules/draw/modify/pedit.rs | 68 ++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 7737ae7cc..4662f0bb6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3247,6 +3247,24 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.refresh_properties(); } + PeditOp::VertexRange { first, last, split } => { + let pieces = self.tabs[i].scene.document.get_entity(handle).and_then(|entity| { + crate::modules::draw::modify::pedit::edit_vertex_range(entity, *first, *last, *split) + }); + if let Some(mut pieces) = pieces { + self.push_undo_snapshot(i, "PEDIT"); + let source = pieces.remove(0); + self.tabs[i].scene.update_entity(source); + for piece in pieces { self.tabs[i].scene.add_entity(piece); } + let updated = self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + if let Some(entity) = updated { command.inject_picked_entity(entity); } + command.on_pedit_applied(); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } else { self.command_line.push_error("PEDIT: invalid vertex range."); } + } // The convert replaces the entity (new handle). PeditOp::ConvertToPolyline => { let converted = self.tabs[i] diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 0b0d69878..318021390 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -37,6 +37,7 @@ enum Mode { AwaitWidth, AwaitLinetype, PolyVertex(usize), + PolyRange { start: usize, end: usize, split: bool }, PolyMove(usize), PolyInsert(usize), PolyWidthStart(usize, f64), @@ -229,7 +230,8 @@ impl CadCommand for PeditCommand { vertex = index + 1 ) .into_owned(), - Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Insert/Move/Width/eXit] :", index + 1), + Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Break/Insert/Move/Straighten/Width/eXit] :", index + 1), + Mode::PolyRange { end, .. } => format!("PEDIT Vertex {} [Next/Previous/Go/eXit] :", end + 1), Mode::PolyMove(index) => format!("PEDIT Specify new location for vertex {}:", index + 1), Mode::PolyWidthStart(_, width) => format!("PEDIT Specify starting width for next segment <{width}>:"), Mode::PolyWidthEnd(_, width) => format!("PEDIT Specify ending width for next segment <{width}>:"), @@ -292,7 +294,8 @@ impl CadCommand for PeditCommand { vec![CmdOption::new(t!("Yes").as_ref(), "Y"), CmdOption::new(t!("No").as_ref(), "N")] } Mode::JoinGather(_) => vec![CmdOption::enter(t!("Join").as_ref())], - Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Width", "W"), CmdOption::new("Exit", "X")], + Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Break", "B"), CmdOption::new("Straighten", "S"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Width", "W"), CmdOption::new("Exit", "X")], + Mode::PolyRange { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Go", "G"), CmdOption::new("Exit", "X")], Mode::AwaitLinetype => vec![CmdOption::new("On", "ON"), CmdOption::new("Off", "OFF")], Mode::MeshVertexMove(_) => vec![], _ => vec![], @@ -369,6 +372,8 @@ impl CadCommand for PeditCommand { fn inject_picked_entity(&mut self, entity: EntityType) { self.entities.insert(entity.common().handle.value(), entity); + let count = self.vertex_count(); + if let Mode::PolyVertex(index) = &mut self.mode { *index = (*index).min(count.saturating_sub(1)); } } fn on_pedit_applied(&mut self) { @@ -461,10 +466,26 @@ impl CadCommand for PeditCommand { } Mode::JoinGather(_) => None, Mode::PolyMove(_) | Mode::PolyInsert(_) => None, + Mode::PolyRange { start, end, split } => { + match up.as_str() { + "N" | "NEXT" => { if *end + 1 < vertex_count { *end += 1; } } + "P" | "PREVIOUS" => { *end = end.saturating_sub(1); } + "X" | "EXIT" => self.mode = Mode::PolyVertex(*start), + "G" | "GO" => { + let (first, last, split) = ((*start).min(*end), (*start).max(*end), *split); + self.mode = Mode::PolyVertex(first); + return Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::VertexRange { first, last, split } }); + } + _ => {} + } + Some(CmdResult::NeedPoint) + } Mode::PolyVertex(index) => { match up.as_str() { "N" | "NEXT" => { if *index + 1 < vertex_count { *index += 1; } } "P" | "PREVIOUS" => { *index = index.saturating_sub(1); } + "B" | "BREAK" => self.mode = Mode::PolyRange { start: *index, end: *index, split: true }, + "S" | "STRAIGHTEN" => self.mode = Mode::PolyRange { start: *index, end: *index, split: false }, "I" | "INSERT" => self.mode = Mode::PolyInsert(*index), "M" | "MOVE" => self.mode = Mode::PolyMove(*index), "W" | "WIDTH" => self.mode = Mode::PolyWidthStart(*index, current_width), @@ -672,7 +693,7 @@ impl CadCommand for PeditCommand { } Mode::MultipleConvert => self.on_text_input("Y").unwrap_or(CmdResult::NeedPoint), Mode::PolyWidthStart(_, width) | Mode::PolyWidthEnd(_, width) => { let value = width.to_string(); self.on_text_input(&value).unwrap_or(CmdResult::NeedPoint) } - Mode::PolyVertex(_) => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), + Mode::PolyVertex(_) | Mode::PolyRange { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), Mode::PolyMove(_) | Mode::PolyInsert(_) => { self.mode = Mode::Options; CmdResult::NeedPoint } Mode::AwaitLinetype => { self.mode = Mode::Options; CmdResult::NeedPoint } Mode::JoinGather(list) if list.len() >= 2 => CmdResult::JoinEntities(list.clone()), @@ -711,6 +732,7 @@ pub enum PeditOp { SetClosed(bool), SetWidth(f64), SetVertexWidth { index: usize, start: f64, end: f64 }, + VertexRange { first: usize, last: usize, split: bool }, SetLinetypeGeneration(bool), EditVertex { index: usize, point: DVec3, insert: bool }, Reverse, @@ -731,9 +753,47 @@ pub enum PeditOp { // ── Apply logic (pure entity edits; driver handles convert/break/marker) ── +/// Edit complete vertex records; no curve interpolation or geometric splitting +/// is needed because both range boundaries are existing vertices. +pub fn edit_vertex_range(entity: &EntityType, first: usize, last: usize, split: bool) -> Option> { + macro_rules! edit { + ($polyline:expr, $closed:expr, $variant:ident, $set_open:expr) => {{ + let polyline = $polyline; + if first > last || last >= polyline.vertices.len() { return None; } + if split && first == last && (first == 0 || last + 1 == polyline.vertices.len()) { return None; } + if !split { + if first == last { return None; } + let mut result = polyline.clone(); + result.vertices.drain(first + 1..last); + result.vertices[first].bulge = 0.0; + Some(vec![EntityType::$variant(result)]) + } else { + let mut vertices = polyline.vertices.clone(); + if $closed { vertices.push(vertices.first()?.clone()); } + let mut results = Vec::new(); + for part in [&vertices[..=first], &vertices[last..]] { + if part.len() < 2 { continue; } + let mut result = polyline.clone(); + result.vertices = part.to_vec(); + ($set_open)(&mut result); + results.push(EntityType::$variant(result)); + } + (!results.is_empty()).then_some(results) + } + }}; + } + match entity { + EntityType::LwPolyline(polyline) => edit!(polyline, polyline.is_closed, LwPolyline, + |value: &mut acadrust::entities::LwPolyline| { value.is_closed = false; }), + EntityType::Polyline2D(polyline) => edit!(polyline, polyline.flags.is_closed(), Polyline2D, + |value: &mut acadrust::entities::Polyline2D| { value.flags.set_closed(false); }), + _ => None, + } +} + pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { - PeditOp::Multiple(_, _) => false, + PeditOp::Multiple(_, _) | PeditOp::VertexRange { .. } => false, PeditOp::SetVertexWidth { index, start, end } => { if !start.is_finite() || !end.is_finite() || *start < 0.0 || *end < 0.0 { return false; } match entity { From be3a2e0d69988cb78fa44c0d1c10c57e2b99e799 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:32:38 +0300 Subject: [PATCH 70/99] Resolve block extension dictionaries through ownership --- src/app/commands/view.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index a54c82c25..47823f7ec 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -36,7 +36,6 @@ fn ensure_draw_order_table( doc.objects.insert(handle, ObjectType::Dictionary(value)); handle }); - doc.xdic_by_handle.insert(block, dictionary); let handle = existing.unwrap_or_else(|| { let handle = doc.allocate_handle(); let mut table = SortEntitiesTable::for_block(block); @@ -879,13 +878,11 @@ impl OpenCADStudio { let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let doc_ref = &self.tabs[i].scene.document; - // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides. - let mut existing_table_handle = None; + // 1. Read existing SortEntitiesTable overrides. let mut overrides: Option> = None; - for (h, obj) in &doc_ref.objects { + for obj in doc_ref.objects.values() { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { - existing_table_handle = Some(*h); if !t.is_empty() { overrides = Some( t.entries() From 8012fc7b452b40c66f5df624c3bcf6f6b69e3722 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:35:57 +0300 Subject: [PATCH 71/99] Interpret spline elevation input as order rather than degree --- src/modules/draw/modify/splinedit.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 379620e27..6f378250f 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -92,7 +92,7 @@ impl SplineditCommand { curve.insert_knot(parameter); } if let Some(degree) = degree { - if degree <= curve.degree() || degree > 26 { return None; } + if degree <= curve.degree() || degree > 25 { return None; } curve = curve.elevated(degree - curve.degree())?; } let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); @@ -181,7 +181,13 @@ impl CadCommand for SplineditCommand { _ => {} }, Step::Elevate => { - if let Some(spline) = upper.parse::().ok().and_then(|degree| self.refined(None, Some(degree))) { + let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1; + let order = upper.parse::().ok().filter(|order| *order >= current_order && *order <= 26)?; + if order == current_order { + self.step = Step::Refine; + return Some(CmdResult::NeedPoint); + } + if let Some(spline) = self.refined(None, Some(order - 1)) { self.step = Step::Refine; return Some(self.replace(spline)); } @@ -264,8 +270,8 @@ impl CadCommand for SplineditCommand { Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { - let degree = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); - self.on_text_input(°ree.to_string()).unwrap_or(CmdResult::NeedPoint) + let order = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); + self.on_text_input(&order.to_string()).unwrap_or(CmdResult::NeedPoint) } Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), } From 929ffdcde8299cee2f029291fa369dd6334c9842 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 11:36:32 +0300 Subject: [PATCH 72/99] Test command replacement identity --- src/app/command_driver.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d0842b399..296f2e978 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -6440,6 +6440,38 @@ fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { .collect() } +#[cfg(test)] +mod command_replacement_tests { + use super::*; + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + #[test] + fn one_to_one_edits_keep_identity_but_type_changes_do_not() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let tab = app.active_tab; + let handle = app.tabs[tab].scene.add_entity(acadrust::EntityType::Line( + Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)), + )); + let owner = app.tabs[tab].scene.document.get_entity(handle).unwrap().common().owner_handle; + + let kept = app.replace_command_entity(tab, handle, vec![acadrust::EntityType::Line( + Line::from_points(Vector3::ZERO, Vector3::new(3.0, 0.0, 0.0)), + )]); + assert_eq!(kept, vec![handle]); + let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); + assert_eq!(edited.common().owner_handle, owner); + assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0)); + + let allocated = app.replace_command_entity(tab, handle, + vec![acadrust::EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0))]); + assert_eq!(allocated.len(), 1); + assert_ne!(allocated[0], handle); + assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); + } +} + #[cfg(test)] mod sketch_constraint_undo_tests { use super::*; From 1bfbd67b7bda726e5b0cc48a4d14f071b51ddcc2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:39:13 +0300 Subject: [PATCH 73/99] Show spline closure options only for the applicable state --- src/modules/draw/modify/splinedit.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 6f378250f..8e30e761f 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -74,6 +74,10 @@ impl SplineditCommand { }).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(index, _)| index) } + fn closed(&self) -> bool { + self.spline.as_ref().is_some_and(|spline| spline.flags.closed || spline.flags.periodic) + } + fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { self.pending = Some(spline.clone()); CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) @@ -109,7 +113,8 @@ impl CadCommand for SplineditCommand { fn prompt(&self) -> String { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), - Step::Options => crate::t!("SPLINEDIT [Close/Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), @@ -122,7 +127,7 @@ impl CadCommand for SplineditCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match self.step { - Step::Options => vec![CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], + Step::Options => vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }, CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], _ => Vec::new(), @@ -164,6 +169,10 @@ impl CadCommand for SplineditCommand { } } "C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => { + if (matches!(upper.as_str(), "C" | "CLOSE") && self.closed()) + || (matches!(upper.as_str(), "O" | "OPEN") && !self.closed()) { + return Some(CmdResult::NeedPoint); + } let mut spline = self.spline.clone()?; let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; apply_to_spline(&mut spline, op); From 5d0ba9cef218800d73067eb2b9e1992971719428 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:39:21 +0300 Subject: [PATCH 74/99] Add persistent nested-copy symbol settings and transactional imports --- src/app/command_driver.rs | 32 +++++++++++++++++++++---- src/app/commands/blocks.rs | 2 +- src/app/mod.rs | 2 ++ src/app/settings.rs | 3 +++ src/app/update/file.rs | 2 ++ src/command.rs | 3 +++ src/io/xref.rs | 1 + src/modules/draw/modify/ncopy.rs | 40 ++++++++++++++++++++++++++------ 8 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 341e98ca6..3dfeaeef3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -929,6 +929,12 @@ impl OpenCADStudio { fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; + if let Some(bind) = self.tabs[i].active_cmd.as_ref().and_then(|command| command.nested_copy_bind_setting()) { + if self.ncopy_bind != bind { + self.ncopy_bind = bind; + self.persist_settings_if_changed(); + } + } let preserve_commit_style = self.tabs[i].active_cmd.as_ref() .is_some_and(|command| command.preserve_commit_style()); let preserve_commit_layer = self.tabs[i] @@ -1054,7 +1060,7 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::CommitEntities(entities) => { + CmdResult::CommitEntities(mut entities) => { let locked_source = entities.iter().find_map(|entity| { let handle = entity.common().handle; (!handle.is_null() @@ -1067,10 +1073,19 @@ impl OpenCADStudio { return Task::none(); } let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities + let symbol_names = self.tabs[i].active_cmd.as_ref() + .and_then(|command| command.nested_copy_symbol_names()).cloned(); + let delta_safe = symbol_names.is_none() && entities .iter() .all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); + if let Some(names) = symbol_names { + let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + if retained > 0 { + self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); + } + self.refresh_layer_panel(); + } for entity in entities { if preserve_commit_style { let _ = self.commit_entity_handle_preserve_style(entity); @@ -1090,12 +1105,21 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::CommitEntitiesAndExit(entities) => { + CmdResult::CommitEntitiesAndExit(mut entities) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities + let symbol_names = self.tabs[i].active_cmd.as_ref() + .and_then(|command| command.nested_copy_symbol_names()).cloned(); + let delta_safe = symbol_names.is_none() && entities .iter() .all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); + if let Some(names) = symbol_names { + let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + if retained > 0 { + self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); + } + self.refresh_layer_panel(); + } for entity in entities { if preserve_commit_style { let _ = self.commit_entity_handle_preserve_style(entity); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 3b884049f..1073501ba 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -656,7 +656,7 @@ impl OpenCADStudio { "NCOPY" | "NCOPYALL" => { let command = crate::modules::draw::modify::ncopy::NcopyCommand::new( - &self.tabs[i].scene.document, + &self.tabs[i].scene.document, self.ncopy_bind, ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c2cd3e73..abd29821f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -511,6 +511,7 @@ pub(super) struct OpenCADStudio { /// Selected-object count past which grips stop being generated /// (GRIPOBJLIMIT, 0..=32767; 0 = no limit). grip_object_limit: i32, + ncopy_bind: bool, /// Drawing viewport cursor style (CURSORTYPE). cursor_type: settings::CursorType, /// Explicit crosshair colour; `None` retains automatic contrast. @@ -3461,6 +3462,7 @@ impl OpenCADStudio { double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, + ncopy_bind: false, cursor_type: settings::CursorType::Crosshair, crosshair_color: None, crosshair_color_input: String::new(), diff --git a/src/app/settings.rs b/src/app/settings.rs index f40d41d54..24674771c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -203,6 +203,8 @@ pub struct UserSettings { /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, + /// Nested-copy symbol handling: false inserts, true binds. + pub ncopy_bind: bool, /// Last Options page; unknown saved names fall back without rejecting the config. #[serde(default, deserialize_with = "deserialize_options_tab")] pub options_tab: crate::ui::window::options::OptionsTab, @@ -372,6 +374,7 @@ impl Default for UserSettings { double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, + ncopy_bind: false, cursor_type: CursorType::Crosshair, crosshair_color: None, lineweight_display_scale: 100, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 518d5c4a5..4beaedfb4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -404,6 +404,7 @@ impl OpenCADStudio { double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, grip_object_limit: self.grip_object_limit, + ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, @@ -467,6 +468,7 @@ impl OpenCADStudio { self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); + self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; self.crosshair_color_input = s diff --git a/src/command.rs b/src/command.rs index 5e3098f3a..3454b018c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1942,6 +1942,9 @@ impl InputKind { pub trait CadCommand: Send { /// Preserve source appearance for commands that extract existing entities. fn preserve_commit_style(&self) -> bool { false } + fn nested_copy_bind_setting(&self) -> Option { None } + /// Symbol localization is committed together with the extracted entities. + fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { None } /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. fn preserve_commit_layer(&self) -> bool { diff --git a/src/io/xref.rs b/src/io/xref.rs index 2aee489d0..fd054a784 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -347,6 +347,7 @@ fn merge_xref_into_block( let old = layer.name.clone(); let new = format!("{}|{}", prefix, old); let mut cloned = layer.clone(); + xref_doc.normalize_imported_layer_defaults(&mut cloned); cloned.name = new.clone(); cloned.set_handle(doc.allocate_handle()); doc.layers.add_or_replace(cloned); diff --git a/src/modules/draw/modify/ncopy.rs b/src/modules/draw/modify/ncopy.rs index 4221a4e94..aa3aa0eaf 100644 --- a/src/modules/draw/modify/ncopy.rs +++ b/src/modules/draw/modify/ncopy.rs @@ -1,11 +1,12 @@ use acadrust::{CadDocument, EntityType, Handle}; use glam::DVec3; +use acadrust::nested_copy::{NestedCopyMode, NestedCopySymbolNames}; use std::collections::{HashMap, HashSet}; use crate::command::{CadCommand, CmdOption, CmdResult, EntityTransform}; use crate::entities::traits::EntityTypeOps; use crate::scene::convert::acad_to_render::RenderObject; -enum Step { Select, Base, Target(DVec3), ArrayCount(DVec3), ArrayTarget(DVec3, usize, bool) } +enum Step { Select, Settings, Base, Target(DVec3), ArrayCount(DVec3), ArrayTarget(DVec3, usize, bool) } pub struct NcopyCommand { nested: HashMap>, hit_paths: HashMap>>, @@ -13,9 +14,12 @@ pub struct NcopyCommand { step: Step, multiple: bool, placements: usize, + mode: NestedCopyMode, + insert_names: NestedCopySymbolNames, + bind_names: NestedCopySymbolNames, } impl NcopyCommand { - pub fn new(document: &CadDocument) -> Self { + pub fn new(document: &CadDocument, bind: bool) -> Self { fn leaves(entity: &EntityType, document: &CadDocument, path: &mut HashSet, output: &mut Vec) { let EntityType::Insert(insert) = entity else { output.push(entity.clone()); return; }; let name = insert.block_name.to_ascii_uppercase(); @@ -43,7 +47,11 @@ impl NcopyCommand { }).collect(); (handle, paths) }).collect(); - Self { nested, hit_paths, selected: Vec::new(), step: Step::Select, multiple: false, placements: 0 } + Self { nested, hit_paths, selected: Vec::new(), step: Step::Select, multiple: false, placements: 0, + mode: if bind { NestedCopyMode::Bind } else { NestedCopyMode::Insert }, + insert_names: document.nested_copy_symbol_names(NestedCopyMode::Insert), + bind_names: document.nested_copy_symbol_names(NestedCopyMode::Bind), + } } fn place(&self, delta: DVec3) -> CmdResult { if !delta.is_finite() { return CmdResult::NeedPoint; } @@ -61,7 +69,8 @@ impl CadCommand for NcopyCommand { fn name(&self) -> &'static str { "NCOPY" } fn prompt(&self) -> String { match self.step { - Step::Select => "Select nested objects:".into(), + Step::Select => format!("Current setting: {}. Select nested objects or [Settings]:", if self.mode == NestedCopyMode::Bind { "Bind" } else { "Insert" }), + Step::Settings => format!("Enter setting [Insert/Bind] <{}>:", if self.mode == NestedCopyMode::Bind { "Bind" } else { "Insert" }), Step::Base => "Specify base point or [Displacement/Multiple] :".into(), Step::Target(_) => "Specify second point or [Array] :".into(), Step::ArrayCount(_) => "Enter number of items to array:".into(), @@ -71,6 +80,8 @@ impl CadCommand for NcopyCommand { } fn options(&self) -> Vec { match self.step { + Step::Select => vec![CmdOption::new("Settings", "S")], + Step::Settings => vec![CmdOption::new("Insert", "I"), CmdOption::new("Bind", "B")], Step::Base => vec![CmdOption::new("Displacement", "D"), CmdOption::new("Multiple", "M")], Step::Target(_) if self.multiple && self.placements > 0 => vec![CmdOption::new("Array", "A"), CmdOption::new("Exit", "E"), CmdOption::new("Undo", "U")], Step::Target(_) => vec![CmdOption::new("Array", "A")], @@ -78,6 +89,10 @@ impl CadCommand for NcopyCommand { _ => Vec::new(), } } + fn nested_copy_bind_setting(&self) -> Option { Some(self.mode == NestedCopyMode::Bind) } + fn nested_copy_symbol_names(&self) -> Option<&NestedCopySymbolNames> { + Some(if self.mode == NestedCopyMode::Bind { &self.bind_names } else { &self.insert_names }) + } fn preserve_commit_style(&self) -> bool { true } fn preserve_commit_layer(&self) -> bool { true } fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::Select) } @@ -109,7 +124,7 @@ impl CadCommand for NcopyCommand { } fn on_point(&mut self, point: DVec3) -> CmdResult { match self.step { - Step::Select => CmdResult::NeedPoint, + Step::Select | Step::Settings => CmdResult::NeedPoint, Step::Base => { self.step = Step::Target(point); CmdResult::NeedPoint } Step::Target(base) => { if !point.is_finite() { return CmdResult::NeedPoint; } @@ -132,15 +147,25 @@ impl CadCommand for NcopyCommand { } fn on_enter(&mut self) -> CmdResult { match self.step { + Step::Settings => { self.step = Step::Select; CmdResult::NeedPoint } Step::Select if !self.selected.is_empty() => { self.step = Step::Base; CmdResult::NeedPoint } Step::Base => { self.step = Step::Target(DVec3::ZERO); CmdResult::NeedPoint } Step::Target(base) if !self.multiple => self.place(base), _ => CmdResult::Cancel, } } - fn point_step_accepts_keywords(&self) -> bool { matches!(self.step, Step::Base | Step::Target(_) | Step::ArrayTarget(..)) } - fn wants_text_input(&self) -> bool { !matches!(self.step, Step::Select) } + fn point_step_accepts_keywords(&self) -> bool { matches!(self.step, Step::Select | Step::Settings | Step::Base | Step::Target(_) | Step::ArrayTarget(..)) } + fn wants_text_input(&self) -> bool { true } fn on_text_input(&mut self, text: &str) -> Option { + if matches!(self.step, Step::Settings) { + match text.trim().to_uppercase().as_str() { + "I" | "INSERT" => self.mode = NestedCopyMode::Insert, + "B" | "BIND" => self.mode = NestedCopyMode::Bind, + _ => return Some(CmdResult::NeedPoint), + } + self.step = Step::Select; + return Some(CmdResult::NeedPoint); + } if let Step::ArrayCount(base) = self.step { if let Ok(count) = text.trim().parse::() { if (2..=32767).contains(&count) { self.step = Step::ArrayTarget(base, count, false); } @@ -148,6 +173,7 @@ impl CadCommand for NcopyCommand { return Some(CmdResult::NeedPoint); } match text.trim().to_uppercase().as_str() { + "S" | "SETTINGS" if matches!(self.step, Step::Select) => { self.step = Step::Settings; Some(CmdResult::NeedPoint) } "A" | "ARRAY" => { if let Step::Target(base) = self.step { self.step = Step::ArrayCount(base); } Some(CmdResult::NeedPoint) From 2486adbb6775e7dc825ee630bff1e7c5c7014f7a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:47:41 +0300 Subject: [PATCH 75/99] Create regions on the shared plane of selected open curves --- src/app/commands/draw.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index d3f73afb9..fdce2b7d4 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1148,10 +1148,17 @@ impl OpenCADStudio { sources.push(*handle); } } - // Assemble remaining selected open edges on the active plane. - // Face discovery and exact curve reconstruction reuse the - // same kernel-backed boundary path used by area selection. - let working_plane = self.tabs[i].ucs_xform().working_plane(); + // Open inputs must share one geometric plane, independently of the UCS. + let open_curves: Vec<_> = self.tabs[i].scene.selected_entities().iter() + .filter(|(handle, _)| !sources.contains(handle)) + .filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity)) + .collect(); + if let Some(plane) = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6) { + let working_plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(plane.origin), + glam::DVec3::from_array(plane.x_axis), + glam::DVec3::from_array(plane.y_axis), + ); let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i].scene .selected_entities().iter().map(|(handle, _)| *handle).collect(); let mut boundary_sources = self.tabs[i].scene @@ -1159,11 +1166,6 @@ impl OpenCADStudio { boundary_sources.retain(|handle, _| { selected_handles.contains(handle) && !sources.contains(handle) }); - let plane = cadkernel::space::Plane::from_axes( - working_plane.origin.to_array(), - working_plane.x.to_array(), - working_plane.y.to_array(), - ); for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) { let paths = crate::scene::exact_hatch_paths( std::slice::from_ref(&ring), &[true], &boundary_sources, 1.0e-6, @@ -1182,6 +1184,9 @@ impl OpenCADStudio { regions.push((region, body)); sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); } + } else if !open_curves.is_empty() { + self.command_line.push_error("REGION: open objects must form coplanar, noncollinear boundaries."); + } if regions.is_empty() { self.command_line .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); From ad9bf07cf7ac8c58d60a65d012efed2251b244c8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:48:34 +0300 Subject: [PATCH 76/99] Fit polylines through kernel arcs and retain vertex tangent metadata --- src/modules/draw/modify/pedit.rs | 271 ++++++++++++++----------------- 1 file changed, 126 insertions(+), 145 deletions(-) diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 318021390..fbf4f5dc6 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -5,7 +5,7 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use cadkernel::geom2d::nurbs::clamped_uniform_knots; use cadkernel::geom2d::NurbsCurve; -use glam::{DVec2, DVec3}; +use glam::DVec3; use rustc_hash::FxHashMap as HashMap; use crate::command::{CadCommand, CmdResult}; @@ -37,6 +37,7 @@ enum Mode { AwaitWidth, AwaitLinetype, PolyVertex(usize), + PolyTangent(usize), PolyRange { start: usize, end: usize, split: bool }, PolyMove(usize), PolyInsert(usize), @@ -59,6 +60,7 @@ pub struct PeditCommand { info: HashMap, mode: Mode, undo_count: usize, + vertex_tangents: HashMap, mesh_smooth_type: acadrust::entities::polygon_mesh::SurfaceSmoothType, mesh_smooth_density: (i16, i16), mesh_vertex_default: isize, @@ -89,6 +91,7 @@ impl PeditCommand { info, mode: Mode::PickTarget, undo_count: 0, + vertex_tangents: HashMap::default(), mesh_smooth_type, mesh_smooth_density: ( surface_u_density.clamp(2, 200), @@ -230,8 +233,9 @@ impl CadCommand for PeditCommand { vertex = index + 1 ) .into_owned(), - Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Break/Insert/Move/Straighten/Width/eXit] :", index + 1), + Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Break/Insert/Move/Straighten/Tangent/Width/eXit] :", index + 1), Mode::PolyRange { end, .. } => format!("PEDIT Vertex {} [Next/Previous/Go/eXit] :", end + 1), + Mode::PolyTangent(_) => "PEDIT Specify direction of vertex tangent:".to_string(), Mode::PolyMove(index) => format!("PEDIT Specify new location for vertex {}:", index + 1), Mode::PolyWidthStart(_, width) => format!("PEDIT Specify starting width for next segment <{width}>:"), Mode::PolyWidthEnd(_, width) => format!("PEDIT Specify ending width for next segment <{width}>:"), @@ -294,7 +298,7 @@ impl CadCommand for PeditCommand { vec![CmdOption::new(t!("Yes").as_ref(), "Y"), CmdOption::new(t!("No").as_ref(), "N")] } Mode::JoinGather(_) => vec![CmdOption::enter(t!("Join").as_ref())], - Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Break", "B"), CmdOption::new("Straighten", "S"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Width", "W"), CmdOption::new("Exit", "X")], + Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Break", "B"), CmdOption::new("Straighten", "S"), CmdOption::new("Tangent", "T"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Width", "W"), CmdOption::new("Exit", "X")], Mode::PolyRange { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Go", "G"), CmdOption::new("Exit", "X")], Mode::AwaitLinetype => vec![CmdOption::new("On", "ON"), CmdOption::new("Off", "OFF")], Mode::MeshVertexMove(_) => vec![], @@ -389,7 +393,7 @@ impl CadCommand for PeditCommand { } fn wants_text_input(&self) -> bool { - !matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_) | Mode::PolyMove(_) | Mode::PolyInsert(_)) + !matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_) | Mode::PolyMove(_) | Mode::PolyInsert(_) | Mode::PolyTangent(_)) } fn on_text_input(&mut self, text: &str) -> Option { @@ -466,6 +470,13 @@ impl CadCommand for PeditCommand { } Mode::JoinGather(_) => None, Mode::PolyMove(_) | Mode::PolyInsert(_) => None, + Mode::PolyTangent(index) => { + let angle = text.trim().parse::().ok()?; + if !angle.is_finite() { return Some(CmdResult::NeedPoint); } + self.vertex_tangents.insert(*index, angle.to_radians().rem_euclid(TAU)); + self.mode = Mode::PolyVertex(*index); + Some(CmdResult::NeedPoint) + } Mode::PolyRange { start, end, split } => { match up.as_str() { "N" | "NEXT" => { if *end + 1 < vertex_count { *end += 1; } } @@ -484,6 +495,7 @@ impl CadCommand for PeditCommand { match up.as_str() { "N" | "NEXT" => { if *index + 1 < vertex_count { *index += 1; } } "P" | "PREVIOUS" => { *index = index.saturating_sub(1); } + "T" | "TANGENT" => self.mode = Mode::PolyTangent(*index), "B" | "BREAK" => self.mode = Mode::PolyRange { start: *index, end: *index, split: true }, "S" | "STRAIGHTEN" => self.mode = Mode::PolyRange { start: *index, end: *index, split: false }, "I" | "INSERT" => self.mode = Mode::PolyInsert(*index), @@ -629,7 +641,7 @@ impl CadCommand for PeditCommand { } "F" | "FIT" => Some(CmdResult::PeditOp { handle, - op: PeditOp::Fit, + op: PeditOp::FitWithTangents(self.vertex_tangents.iter().map(|(index, angle)| (*index, *angle)).collect()), }), "S" | "SPLINE" => Some(CmdResult::PeditOp { handle, @@ -659,6 +671,20 @@ impl CadCommand for PeditCommand { } fn on_point(&mut self, point: DVec3) -> CmdResult { + if let Mode::PolyTangent(index) = self.mode { + let Some(entity) = self.target.and_then(|handle| self.entities.get(&handle.value())) else { return CmdResult::NeedPoint; }; + let (normal, elevation, location) = match entity { + EntityType::LwPolyline(p) => { let Some(v) = p.vertices.get(index) else { return CmdResult::NeedPoint; }; (p.normal, p.elevation, [v.location.x, v.location.y]) } + EntityType::Polyline2D(p) => { let Some(v) = p.vertices.get(index) else { return CmdResult::NeedPoint; }; (p.normal, p.elevation, [v.location.x, v.location.y]) } + _ => return CmdResult::NeedPoint, + }; + let Some(local) = crate::entities::curve::ocs_plane(normal, elevation).project([point.x, point.y, point.z]) else { return CmdResult::NeedPoint; }; + let delta = cadkernel::geom2d::Vec2::new(local[0] - location[0], local[1] - location[1]); + if delta.length_squared() <= 1e-24 { return CmdResult::NeedPoint; } + self.vertex_tangents.insert(index, delta.angle().rem_euclid(TAU)); + self.mode = Mode::PolyVertex(index); + return CmdResult::NeedPoint; + } if let Mode::PolyMove(index) | Mode::PolyInsert(index) = self.mode { if !point.is_finite() { return CmdResult::NeedPoint; } let Some(handle) = self.target else { return CmdResult::Cancel; }; @@ -739,6 +765,7 @@ pub enum PeditOp { /// Replace the picked Line/Arc with an equivalent LwPolyline (#263). ConvertToPolyline, Fit, + FitWithTangents(Vec<(usize, f64)>), Spline, Decurve, SetMeshClosedM(bool), @@ -890,23 +917,20 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { } _ => false, }, - PeditOp::Fit => match entity { - EntityType::LwPolyline(p) => fit_curve(p), - _ => false, - }, + PeditOp::Fit | PeditOp::FitWithTangents(_) => { + let overrides = match op { PeditOp::FitWithTangents(values) => values.as_slice(), _ => &[] }; + let Some(fitted) = fit_entity(entity, overrides) else { return false; }; + *entity = fitted; + true + } PeditOp::Spline => match entity { EntityType::LwPolyline(p) => spline_smooth(p), _ => false, }, - PeditOp::Decurve => match entity { - EntityType::LwPolyline(p) => { - for v in &mut p.vertices { - v.bulge = 0.0; - } - true - } - _ => false, - }, + PeditOp::Decurve => { + let Some(straight) = decurve_entity(entity) else { return false; }; + *entity = straight; true + } PeditOp::SetMeshClosedM(closed) => match entity { EntityType::PolygonMesh(mesh) => { if mesh.is_closed_m() == *closed { @@ -1094,139 +1118,96 @@ mod convert_tests { // ── Curve fitting ───────────────────────────────────────────────────────── -fn vert_xy(v: &LwVertex) -> DVec2 { - DVec2::new(v.location.x, v.location.y) -} - -/// Wrap an angle into (-pi, pi]. -fn wrap_angle(mut a: f64) -> f64 { - while a > std::f64::consts::PI { - a -= TAU; - } - while a <= -std::f64::consts::PI { - a += TAU; - } - a -} - -/// Bulge of the arc `a` -> `b` whose tangent AT `a` is `t` (entry form). -fn bulge_entry(a: DVec2, t: DVec2, b: DVec2) -> f64 { - let d = b - a; - if d.length_squared() < 1e-12 { - return 0.0; - } - (wrap_angle(d.y.atan2(d.x) - t.y.atan2(t.x)) / 2.0).tan() -} - -/// Bulge of the arc `a` -> `b` whose tangent AT `b` is `t` (exit form). -fn bulge_exit(a: DVec2, b: DVec2, t: DVec2) -> f64 { - let d = b - a; - if d.length_squared() < 1e-12 { - return 0.0; - } - (wrap_angle(t.y.atan2(t.x) - d.y.atan2(d.x)) / 2.0).tan() -} - -/// PEDIT Fit: replace every segment with a BIARC — two arcs that leave the -/// start vertex along its tangent, meet each other tangentially at an -/// inserted knee vertex, and arrive at the end vertex along ITS tangent. -/// Both segments at a vertex share that vertex's tangent (the average of the -/// neighbouring chords), so the whole run is tangent-continuous — arcs -/// mutually tangent everywhere, which a single arc per segment cannot do. -fn fit_curve(p: &mut acadrust::LwPolyline) -> bool { - let n = p.vertices.len(); - if n < 3 { - return false; - } - let pts: Vec = p.vertices.iter().map(vert_xy).collect(); - let chord = |i: usize| -> DVec2 { - let a = pts[i % n]; - let b = pts[(i + 1) % n]; - (b - a).normalize_or_zero() +fn fit_entity(entity: &EntityType, overrides: &[(usize, f64)]) -> Option { + use acadrust::entities::{Polyline2D, PolylineFlags, Vertex2D, VertexFlags}; + let mut polyline = match entity { + EntityType::Polyline2D(p) => p.clone(), + EntityType::LwPolyline(p) => { + let mut result = Polyline2D::new(); + result.common = p.common.clone(); result.normal = p.normal; + result.elevation = p.elevation; result.thickness = p.thickness; + result.flags = PolylineFlags::from_bits((p.is_closed as u16) | if p.plinegen { 128 } else { 0 }); + result.vertices = p.vertices.iter().map(|v| { + let mut vertex = Vertex2D::new(Vector3::new(v.location.x, v.location.y, p.elevation)); + vertex.start_width = if p.constant_width != 0.0 { p.constant_width } else { v.start_width }; + vertex.end_width = if p.constant_width != 0.0 { p.constant_width } else { v.end_width }; + vertex.id = v.vertex_id; + vertex + }).collect(); + result + } + _ => return None, }; - // Per-vertex tangents. - let tangent = |i: usize| -> DVec2 { - if !p.is_closed && i == 0 { - return chord(0); + // Refit the defining vertices, never the previously inserted fit knees. + let mut originals = defining_vertices(&polyline); + for vertex in &mut originals { + if vertex.start_width == 0.0 { vertex.start_width = polyline.start_width; } + if vertex.end_width == 0.0 { vertex.end_width = polyline.end_width; } + } + for &(index, angle) in overrides { + let vertex = originals.get_mut(index)?; + if !angle.is_finite() { return None; } + vertex.curve_tangent = angle.rem_euclid(TAU); + vertex.flags = VertexFlags::from_bits(vertex.flags.bits() | 2); + } + let points: Vec<_> = originals.iter().map(|v| [v.location.x, v.location.y]).collect(); + let directions: Vec<_> = originals.iter().map(|v| (v.flags.bits() & 2 != 0).then(|| [v.curve_tangent.cos(), v.curve_tangent.sin()])).collect(); + let fitted = cadkernel::geom2d::fit_arc_chain(&points, polyline.flags.is_closed(), &directions)?; + let mut vertices = Vec::with_capacity(fitted.len()); + for (i, fitted_vertex) in fitted.iter().enumerate() { + let original = &originals[fitted_vertex.source]; + let mut vertex = original.clone(); + vertex.location = Vector3::new(fitted_vertex.point[0], fitted_vertex.point[1], polyline.elevation); + vertex.bulge = fitted_vertex.bulge; + let end_fraction = fitted.get(i + 1).filter(|next| next.source == fitted_vertex.source).map_or(1.0, |next| next.fraction); + vertex.start_width = original.start_width + (original.end_width - original.start_width) * fitted_vertex.fraction; + vertex.end_width = original.start_width + (original.end_width - original.start_width) * end_fraction; + if fitted_vertex.inserted { vertex.flags = VertexFlags::EXTRA_VERTEX; vertex.id = 0; } + vertices.push(vertex); + } + polyline.start_width = originals.first()?.start_width; + polyline.end_width = originals.first()?.end_width; + polyline.vertices = vertices; + polyline.flags = PolylineFlags::from_bits((polyline.flags.bits() & !4) | 2); + Some(EntityType::Polyline2D(polyline)) +} + +fn defining_vertices(polyline: &acadrust::entities::Polyline2D) -> Vec { + polyline.vertices.iter().enumerate().filter(|(_, vertex)| vertex.flags.bits() & 1 == 0).map(|(index, vertex)| { + let mut original = vertex.clone(); + if let Some(knee) = polyline.vertices[index + 1..].iter().take_while(|next| next.flags.bits() & 1 != 0).last() { + original.end_width = knee.end_width; } - if !p.is_closed && i == n - 1 { - return chord(n - 2); - } - let prev = chord((i + n - 1) % n); - let cur = chord(i % n); - let sum = prev + cur; - if sum.length_squared() < 1e-12 { - cur - } else { - sum.normalize() + original + }).collect() +} + +fn decurve_entity(entity: &EntityType) -> Option { + let mut result = match entity { + EntityType::LwPolyline(p) => p.clone(), + EntityType::Polyline2D(p) => { + let mut result = acadrust::LwPolyline::new(); + result.common = p.common.clone(); result.normal = p.normal; + result.elevation = p.elevation; result.thickness = p.thickness; + result.is_closed = p.flags.is_closed(); result.plinegen = p.flags.bits() & 128 != 0; + result.vertices = defining_vertices(p).iter().map(|v| { + let mut vertex = LwVertex::new(Vector2::new(v.location.x, v.location.y)); + vertex.vertex_id = v.id; + vertex.start_width = if v.start_width == 0.0 { p.start_width } else { v.start_width }; + vertex.end_width = if v.end_width == 0.0 { p.end_width } else { v.end_width }; + vertex + }).collect(); + result } + _ => return None, }; - let seg_count = if p.is_closed { n } else { n - 1 }; - let mut out: Vec = Vec::with_capacity(seg_count * 2 + 1); - for i in 0..seg_count { - let p0 = pts[i % n]; - let p1 = pts[(i + 1) % n]; - let t0 = tangent(i % n); - let t1 = tangent((i + 1) % n); - let d = p1 - p0; - let src = &p.vertices[i % n]; - let mut push = |q: DVec2, bulge: f64| { - let mut v = LwVertex::new(Vector2::new(q.x, q.y)); - v.bulge = bulge; - v.start_width = src.start_width; - v.end_width = src.end_width; - out.push(v); - }; - if d.length_squared() < 1e-12 { - push(p0, 0.0); - continue; + for vertex in &mut result.vertices { vertex.bulge = 0.0; } + if let Some(width) = result.vertices.first().map(|v| v.start_width) { + if result.vertices.iter().all(|v| v.start_width == width && v.end_width == width) { + result.constant_width = width; } - // Both tangents already aligned with the chord: keep it straight. - let dn = d.normalize(); - if (dn - t0).length_squared() < 1e-12 && (dn - t1).length_squared() < 1e-12 { - push(p0, 0.0); - continue; - } - // Classic equal-parameter biarc: apexes A = p0 + k*t0, B = p1 - k*t1 - // and the knee M at their midpoint. Tangency at M needs |AB| = 2k, - // i.e. 2(1 - t0.t1) k^2 + 2 (d.(t0+t1)) k - |d|^2 = 0 — A and B are - // then the tangent-line apexes of their arcs, so both arcs' tangents - // at M run along AB and the pair is tangent-continuous. - let dot_tt = t0.dot(t1).clamp(-1.0, 1.0); - let b_lin = d.dot(t0 + t1); - let a_quad = 2.0 * (1.0 - dot_tt); - let k = if a_quad.abs() > 1e-9 { - let disc = b_lin * b_lin + a_quad * d.length_squared(); - if disc < 0.0 { - push(p0, bulge_entry(p0, t0, p1)); - continue; - } - (-b_lin + disc.sqrt()) / a_quad - } else if b_lin.abs() > 1e-9 { - // Parallel tangents: the quadratic degenerates to one root. - d.length_squared() / (2.0 * b_lin) - } else { - // Anti-parallel S with no along-tangent reach: symmetric split. - (d.length_squared() / 4.0).sqrt() - }; - if !k.is_finite() || k <= 1e-9 { - push(p0, bulge_entry(p0, t0, p1)); - continue; - } - let m = (p0 + t0 * k + p1 - t1 * k) * 0.5; - push(p0, bulge_entry(p0, t0, m)); - push(m, bulge_exit(m, p1, t1)); } - if !p.is_closed { - let mut last = p.vertices[n - 1].clone(); - last.bulge = 0.0; - out.push(last); - } - if out.len() < 2 { - return false; - } - p.vertices = out; - true + Some(EntityType::LwPolyline(result)) } /// PEDIT Spline: replace the shape with a sampled uniform cubic B-spline of From b51672470dc3a3d63b6d7f2d53b32165a7abba04 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:50:22 +0300 Subject: [PATCH 77/99] Preserve spline construction through closure and reopening --- src/modules/draw/modify/splinedit.rs | 101 +++++++++++++++++---------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 8e30e761f..c549aba05 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -176,6 +176,7 @@ impl CadCommand for SplineditCommand { let mut spline = self.spline.clone()?; let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; apply_to_spline(&mut spline, op); + if self.spline.as_ref() == Some(&spline) { return Some(CmdResult::NeedPoint); } return Some(self.replace(spline)); } _ => {} @@ -297,44 +298,72 @@ pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle } fn apply_to_spline(spline: &mut acadrust::entities::Spline, op: &str) { - match op { - "__SPLINEDIT_CLOSE__" => { - if spline.control_points.len() >= 2 { - let first = spline.control_points[0]; - spline.control_points.push(first); - // Regenerate clamped knots. - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); - spline.fit_points.clear(); - } - } - "__SPLINEDIT_OPEN__" => { - if spline.control_points.len() >= 2 { - let n = spline.control_points.len(); - let first = spline.control_points[0]; - let last = spline.control_points[n - 1]; - if (first.x - last.x).abs() < 1e-9 - && (first.y - last.y).abs() < 1e-9 - && (first.z - last.z).abs() < 1e-9 - { - spline.control_points.pop(); - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); - spline.fit_points.clear(); - } - } - } - "__SPLINEDIT_REVERSE__" => { - *spline = super::reverse::reverse_spline(spline); - } - _ => {} - } + let result = match op { + "__SPLINEDIT_CLOSE__" => change_closure(spline, true), + "__SPLINEDIT_OPEN__" => change_closure(spline, false), + "__SPLINEDIT_REVERSE__" => Some(super::reverse::reverse_spline(spline)), + _ => None, + }; + if let Some(result) = result { *spline = result; } } +fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option { + use cadkernel::space::{NurbsCurve3, Parameterization}; + if (source.flags.closed || source.flags.periodic) == closed { return None; } + let fit_method = !source.fit_points.is_empty() || source.dwg_flags1 & 1 != 0 || source.dxf_flags & 32 != 0; + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let xyz = |point: &Vector3| [point.x, point.y, point.z]; + let controls: Vec<_> = source.control_points.iter().map(xyz).collect(); + let degree = usize::try_from(source.degree).ok()?; + let weights = if source.weights.is_empty() { vec![1.0; controls.len()] } else { source.weights.clone() }; + if fit_method && weights.windows(2).any(|pair| pair[0] != pair[1]) { return None; } + let stored_curve = || NurbsCurve3::new_strict(degree, controls.clone(), source.knots.clone(), weights.clone()); + let mut fit_points = Vec::new(); + let curve = if fit_method { + fit_points = source.fit_points.iter().map(xyz).collect(); + if fit_points.is_empty() { + let curve = stored_curve()?; + let (start, end) = curve.domain(); + let mut parameters: Vec<_> = curve.knots().iter().copied() + .filter(|parameter| *parameter >= start && if closed { *parameter <= end } else { *parameter < end }).collect(); + parameters.dedup(); + fit_points = parameters.into_iter().map(|parameter| curve.point_at_knot(parameter)).collect(); + } + let curve = if closed { + NurbsCurve3::interpolate_periodic(&fit_points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&fit_points, None, None, parameterization)? + }; + curve.compact_knots(source.control_tolerance.max(1e-9))? + } else if closed { + NurbsCurve3::from_weighted_control_polygon(degree, &controls, &weights, true)? + } else { + let curve = stored_curve()?; + curve.without_control_vertex(controls.len().checked_sub(1)?)? + }; + let mut result = source.clone(); + result.degree = curve.degree() as i32; + result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + result.knots = curve.knots().to_vec(); + result.weights = curve.weights().to_vec(); + result.flags.closed = closed; + result.flags.periodic = closed; + if closed { result.dxf_flags |= 2048; } else { result.dxf_flags &= !2048; } + result.flags.rational = if fit_method { false } else { source.flags.rational || curve.is_rational() }; + result.fit_points = if !closed && fit_method { + fit_points.iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect() + } else { Vec::new() }; + if fit_method { + result.dwg_flags1 |= 1; + result.dxf_flags |= 32 | 1024; + result.begin_tangent = Vector3::ZERO; + result.end_tangent = Vector3::ZERO; + } + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + Some(result) +} // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand From 499beb718f812c1c4f65db045fa893e624b0dbe0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:50:25 +0300 Subject: [PATCH 78/99] Preserve polylines when fitting attributes are invalid --- src/modules/draw/modify/pedit.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index fbf4f5dc6..1878e01b9 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -680,7 +680,7 @@ impl CadCommand for PeditCommand { }; let Some(local) = crate::entities::curve::ocs_plane(normal, elevation).project([point.x, point.y, point.z]) else { return CmdResult::NeedPoint; }; let delta = cadkernel::geom2d::Vec2::new(local[0] - location[0], local[1] - location[1]); - if delta.length_squared() <= 1e-24 { return CmdResult::NeedPoint; } + if !delta.length_squared().is_finite() || delta.length_squared() <= 1e-24 { return CmdResult::NeedPoint; } self.vertex_tangents.insert(index, delta.angle().rem_euclid(TAU)); self.mode = Mode::PolyVertex(index); return CmdResult::NeedPoint; @@ -1144,6 +1144,8 @@ fn fit_entity(entity: &EntityType, overrides: &[(usize, f64)]) -> Option Date: Fri, 11 Sep 2026 11:51:07 +0300 Subject: [PATCH 79/99] Update drawing command dependencies --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 858c21374..38b4901c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=d013c384f86a11bae7079c9be3ad7c450676d46e#d013c384f86a11bae7079c9be3ad7c450676d46e" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=a59ff9850d261bed3df74001762cfa327bc1ced5#a59ff9850d261bed3df74001762cfa327bc1ced5" dependencies = [ "ahash 0.8.12", "anyhow", @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0409935937c85ffb6bbee60ec4d02fe074a23b48#0409935937c85ffb6bbee60ec4d02fe074a23b48" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8846733d232a9a36177cee26775f04de52f19351#8846733d232a9a36177cee26775f04de52f19351" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0409935937c85ffb6bbee60ec4d02fe074a23b48#0409935937c85ffb6bbee60ec4d02fe074a23b48" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8846733d232a9a36177cee26775f04de52f19351#8846733d232a9a36177cee26775f04de52f19351" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index e612c3683..ae950c461 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,9 +30,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "d013c384f86a11bae7079c9be3ad7c450676d46e", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0409935937c85ffb6bbee60ec4d02fe074a23b48", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0409935937c85ffb6bbee60ec4d02fe074a23b48" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8846733d232a9a36177cee26775f04de52f19351", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8846733d232a9a36177cee26775f04de52f19351" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 4affb39fc..77bd82783 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "d013c384f86a11bae7079c9be3ad7c450676d46e", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "d013c384f86a11bae7079c9be3ad7c450676d46e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index fa6e16fee..3c855ba24 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "d013c384f86a11bae7079c9be3ad7c450676d46e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 233470767..644d125d2 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "d013c384f86a11bae7079c9be3ad7c450676d46e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } From dd9ef2f23666a61481d0c45d46513d2d924cb99d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:51:54 +0300 Subject: [PATCH 80/99] Remap pending vertex tangents through polyline edits and undo --- src/modules/draw/modify/pedit.rs | 37 ++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 1878e01b9..4450f0313 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -61,6 +61,8 @@ pub struct PeditCommand { mode: Mode, undo_count: usize, vertex_tangents: HashMap, + pending_tangents: Option>, + tangent_history: Vec>, mesh_smooth_type: acadrust::entities::polygon_mesh::SurfaceSmoothType, mesh_smooth_density: (i16, i16), mesh_vertex_default: isize, @@ -92,6 +94,8 @@ impl PeditCommand { mode: Mode::PickTarget, undo_count: 0, vertex_tangents: HashMap::default(), + pending_tangents: None, + tangent_history: Vec::new(), mesh_smooth_type, mesh_smooth_density: ( surface_u_density.clamp(2, 200), @@ -147,6 +151,25 @@ impl PeditCommand { } } + fn mapped_tangents(&self, op: &PeditOp) -> Option> { + let count = self.vertex_count(); + match op { + PeditOp::Reverse => Some(self.vertex_tangents.iter().filter_map(|(&index, &angle)| + (index < count).then_some((count.saturating_sub(index + 1), (angle + std::f64::consts::PI).rem_euclid(TAU)))).collect()), + PeditOp::EditVertex { index, insert: true, .. } => Some(self.vertex_tangents.iter().map(|(&old, &angle)| + (if old > *index { old + 1 } else { old }, angle)).collect()), + PeditOp::VertexRange { first, last, split } => Some(self.vertex_tangents.iter().filter_map(|(&index, &angle)| { + if *split { + if *first > 0 { (index <= *first).then_some((index, angle)) } + else { (index >= *last).then_some((index.saturating_sub(*last), angle)) } + } else if index > *first && index < *last { None } + else { Some((if index >= *last { index.saturating_sub(last.saturating_sub(*first + 1)) } else { index }, angle)) } + }).collect()), + PeditOp::Fit | PeditOp::FitWithTangents(_) | PeditOp::Decurve | PeditOp::Spline => Some(HashMap::default()), + _ => None, + } + } + fn vertex_width(&self, index: usize) -> f64 { let entity = self.target.and_then(|handle| self.entities.get(&handle.value())); match entity { @@ -381,6 +404,8 @@ impl CadCommand for PeditCommand { } fn on_pedit_applied(&mut self) { + self.tangent_history.push(self.vertex_tangents.clone()); + if let Some(mapped) = self.pending_tangents.take() { self.vertex_tangents = mapped; } self.undo_count = self.undo_count.saturating_add(1); self.multiple_history.push(self.pending_multiple.take().unwrap_or_else(|| self.multiple.clone())); if let Some((m_direction, closed)) = self.pending_mesh_closed.take() { @@ -398,6 +423,7 @@ impl CadCommand for PeditCommand { fn on_text_input(&mut self, text: &str) -> Option { self.pending_mesh_closed = None; + self.pending_tangents = None; let up = text.trim().to_uppercase(); let mesh_size = self.mesh_size(); let vertex_count = self.vertex_count(); @@ -485,7 +511,9 @@ impl CadCommand for PeditCommand { "G" | "GO" => { let (first, last, split) = ((*start).min(*end), (*start).max(*end), *split); self.mode = Mode::PolyVertex(first); - return Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::VertexRange { first, last, split } }); + let op = PeditOp::VertexRange { first, last, split }; + self.pending_tangents = self.mapped_tangents(&op); + return Some(CmdResult::PeditOp { handle: self.target?, op }); } _ => {} } @@ -615,6 +643,7 @@ impl CadCommand for PeditCommand { "U" | "UNDO" if self.undo_count > 0 => { self.undo_count -= 1; self.mesh_closed_history.pop(); + if let Some(tangents) = self.tangent_history.pop() { self.vertex_tangents = tangents; } if let Some(previous) = self.multiple_history.pop() { self.multiple = previous; if !self.multiple.is_empty() { self.target = self.multiple.first().copied(); } @@ -667,10 +696,12 @@ impl CadCommand for PeditCommand { } } }; + if let Some(CmdResult::PeditOp { op, .. }) = &result { self.pending_tangents = self.mapped_tangents(op); } self.multiple_result(result) } fn on_point(&mut self, point: DVec3) -> CmdResult { + self.pending_tangents = None; if let Mode::PolyTangent(index) = self.mode { let Some(entity) = self.target.and_then(|handle| self.entities.get(&handle.value())) else { return CmdResult::NeedPoint; }; let (normal, elevation, location) = match entity { @@ -690,7 +721,9 @@ impl CadCommand for PeditCommand { let Some(handle) = self.target else { return CmdResult::Cancel; }; let insert = matches!(self.mode, Mode::PolyInsert(_)); self.mode = Mode::PolyVertex(index); - return CmdResult::PeditOp { handle, op: PeditOp::EditVertex { index, point, insert } }; + let op = PeditOp::EditVertex { index, point, insert }; + self.pending_tangents = self.mapped_tangents(&op); + return CmdResult::PeditOp { handle, op }; } if let Mode::MeshVertexMove(index) = self.mode { let Some(handle) = self.target else { From 57c9e93d0c7d5bedb275713a08a162aa9755697b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:04:54 +0300 Subject: [PATCH 81/99] Join compatible curves during spline editing with grouped undo --- src/app/command_driver.rs | 3 ++ src/command.rs | 3 ++ src/modules/draw/modify/splinedit.rs | 52 +++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8b80557e9..16d57ae5f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -417,10 +417,13 @@ impl OpenCADStudio { } } if let StepInput::SelectionComplete(handles) = &input { + let exclude_locked = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.selection_entities_exclude_locked()); let entities = { let scene = &self.tabs[i].scene; handles .iter() + .filter(|handle| !exclude_locked || !scene.is_layer_locked(**handle)) .filter_map(|handle| { scene.document.get_entity(*handle).cloned().map(|entity| { let surface_area = scene diff --git a/src/command.rs b/src/command.rs index 177d25894..919c869e4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2301,6 +2301,9 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Exclude locked-layer entities from injected selection geometry. + fn selection_entities_exclude_locked(&self) -> bool { false } + fn inject_selection_entities(&mut self, _entities: Vec) {} fn area_preview_regions(&self) -> Option> { diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index c549aba05..5e260b414 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -33,6 +33,7 @@ enum Step { SelectSpline, Options, Refine, + Join, Add, Delete, Elevate, @@ -48,11 +49,12 @@ pub struct SplineditCommand { pending: Option, history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, pick_context: Option, + join_candidates: Vec, } impl SplineditCommand { pub fn new() -> Self { - Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None } + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new() } } fn picked_vertex(&self, point: DVec3) -> Option { @@ -83,6 +85,24 @@ impl SplineditCommand { CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) } + fn finish_join(&mut self) -> CmdResult { + self.step = Step::Options; + let candidates = std::mem::take(&mut self.join_candidates); + let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; + let selected: Vec<_> = candidates.iter().filter(|item| item.handle != self.handle) + .map(|item| (item.handle, &item.entity)).collect(); + let Some((EntityType::Spline(mut spline), consumed)) = super::join::join_to_source( + &EntityType::Spline(source.clone()), &selected) else { return CmdResult::NeedPoint; }; + spline.dwg_flags1 &= !1; + spline.dxf_flags &= !(32 | 1024); + self.pending = Some(spline.clone()); + let mut replacements = vec![(self.handle, vec![EntityType::Spline(spline)])]; + replacements.extend(consumed.into_iter().map(|handle| (handle, Vec::new()))); + // The host stores one document snapshot, including every consumed curve. + // The existing command-local Undo restores that snapshot and the source cache. + CmdResult::ReplaceManyContinue(replacements) + } + fn refined(&self, point: Option, degree: Option) -> Option { let source = self.spline.as_ref()?; let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; @@ -114,7 +134,8 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), - Step::Options => crate::t!("SPLINEDIT [Close/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), @@ -127,12 +148,26 @@ impl CadCommand for SplineditCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match self.step { - Step::Options => vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }, CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], + Step::Options => { + let mut options = vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }]; + if !self.closed() { options.push(CmdOption::new("Join", "J")); } + options.extend([CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")]); + options + }, Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], _ => Vec::new(), } } + fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Join) } + fn selection_entities_exclude_locked(&self) -> bool { matches!(self.step, Step::Join) } + fn inject_selection_entities(&mut self, entities: Vec) { + if matches!(self.step, Step::Join) { self.join_candidates = entities; } + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.join_candidates.retain(|item| handles.contains(&item.handle) && item.handle != self.handle); + CmdResult::NeedPoint + } fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectSpline) } fn inject_before_entity_pick(&self) -> bool { true } fn inject_picked_entity(&mut self, entity: EntityType) { @@ -152,12 +187,13 @@ impl CadCommand for SplineditCommand { } } } - fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Join | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { let upper = text.trim().to_uppercase(); if upper.is_empty() { return Some(self.on_enter()); } match self.step { Step::Options => match upper.as_str() { + "J" | "JOIN" if !self.closed() => { self.join_candidates.clear(); self.step = Step::Join; }, "R" | "REFINE" => self.step = Step::Refine, "M" | "MOVE" => self.step = Step::Move { index: 0, refine: false }, "X" | "EXIT" => return Some(CmdResult::Cancel), @@ -277,6 +313,7 @@ impl CadCommand for SplineditCommand { fn on_enter(&mut self) -> CmdResult { match self.step { Step::SelectSpline | Step::Options => CmdResult::Cancel, + Step::Join => self.finish_join(), Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { @@ -286,6 +323,13 @@ impl CadCommand for SplineditCommand { Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), } } + fn on_escape(&mut self) -> CmdResult { + if matches!(self.step, Step::Join) { + self.join_candidates.clear(); + self.step = Step::Options; + CmdResult::NeedPoint + } else { CmdResult::Cancel } + } fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { vec![] } } /// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity. From 493c7570ee69ad2f56a9899c3f67d82dd7848179 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 12:04:55 +0300 Subject: [PATCH 82/99] Update curve editing dependencies --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38b4901c3..a13687e90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=a59ff9850d261bed3df74001762cfa327bc1ced5#a59ff9850d261bed3df74001762cfa327bc1ced5" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=b49cbbfc49ba267712045831a601d2f3d0ed386a#b49cbbfc49ba267712045831a601d2f3d0ed386a" dependencies = [ "ahash 0.8.12", "anyhow", @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8846733d232a9a36177cee26775f04de52f19351#8846733d232a9a36177cee26775f04de52f19351" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b95889d5e2d5271fdf4d43fb1d883f5b091dd738#b95889d5e2d5271fdf4d43fb1d883f5b091dd738" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8846733d232a9a36177cee26775f04de52f19351#8846733d232a9a36177cee26775f04de52f19351" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b95889d5e2d5271fdf4d43fb1d883f5b091dd738#b95889d5e2d5271fdf4d43fb1d883f5b091dd738" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index ae950c461..8c262aed1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,9 +30,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8846733d232a9a36177cee26775f04de52f19351", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8846733d232a9a36177cee26775f04de52f19351" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b95889d5e2d5271fdf4d43fb1d883f5b091dd738", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b95889d5e2d5271fdf4d43fb1d883f5b091dd738" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 77bd82783..2cc7cd0bd 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 3c855ba24..4c162959e 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 644d125d2..2e0b57661 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a59ff9850d261bed3df74001762cfa327bc1ced5", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } From 818ab87e9cecac94cd81c9481eb83d14d6284319 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:10:31 +0300 Subject: [PATCH 83/99] Apply zero helix properties and signed height direction --- src/entities/helix.rs | 47 ++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/entities/helix.rs b/src/entities/helix.rs index aa753c6e3..f3417e306 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -52,10 +52,13 @@ fn spline_curve(spline: &Spline) -> Option { fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> { let base = point(helix.axis_base_point); - let axis = point(helix.axis_vector).normalize()?; - let radial = point(helix.start_point) - base; - let start_direction = (radial - axis * radial.dot(axis)).normalize()?; - Some((base, axis, start_direction)) + let tangent = spline_curve(&helix.spline).map(|curve| curve.tangent_at(0.0)).unwrap_or([0.0;3]); + let (axis, radial, _) = HelixCurve::frame_from_points(base.to_array(),point(helix.axis_vector).to_array(),point(helix.start_point).to_array(),tangent)?; + Some((base,Vec3::from(axis),Vec3::from(radial))) +} + +fn base_radius(helix: &Helix) -> Option { + radius_from_axis(helix, point(helix.start_point)) } fn radius_from_axis(helix: &Helix, value: Vec3) -> Option { @@ -65,6 +68,7 @@ fn radius_from_axis(helix: &Helix, value: Vec3) -> Option { } fn top_radius(helix: &Helix) -> f64 { + if helix.turns == 0.0 { return helix.radius; } spline_curve(&helix.spline) .map(|curve| Vec3::from(curve.point_at(1.0))) .and_then(|endpoint| radius_from_axis(helix, endpoint)) @@ -77,7 +81,7 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { base_center: base.to_array(), axis_direction: axis.to_array(), start_direction: start_direction.to_array(), - base_radius: helix.radius, + base_radius: base_radius(helix)?, top_radius, height: helix.turns * helix.turn_height, turns: helix.turns, @@ -105,12 +109,18 @@ fn perpendicular(axis: Vec3) -> Option { } fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { - let Some(curve) = kernel_curve(helix, top_radius) else { + rebuild_with_radial(helix, top_radius, None) +} + +fn rebuild_with_radial(helix: &mut Helix, top_radius: f64, radial: Option) -> bool { + let Some(mut curve) = kernel_curve(helix, top_radius) else { return false; }; + if let Some(radial) = radial { curve.start_direction = radial.to_array(); } let Some(nurbs) = curve.nurbs() else { return false; }; + helix.radius = top_radius; helix.spline.degree = nurbs.degree() as i32; helix.spline.knots = nurbs.knots().to_vec(); helix.spline.control_points = nurbs @@ -127,7 +137,7 @@ fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { helix.spline.flags.closed = false; helix.spline.flags.periodic = false; helix.spline.flags.rational = nurbs.is_rational(); - helix.spline.flags.planar = false; + helix.spline.flags.planar = curve.height == 0.0; helix.spline.flags.linear = false; true } @@ -194,7 +204,7 @@ fn properties(helix: &Helix) -> Vec { edit(t!("Height").as_ref(), "height", height), edit(t!("Turns").as_ref(), "turns", helix.turns), edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height), - edit(t!("Base radius").as_ref(), "base_radius", helix.radius), + edit(t!("Base radius").as_ref(), "base_radius", base_radius(helix).unwrap_or(0.0)), edit(t!("Top radius").as_ref(), "top_radius", top_radius), choice(t!("Twist").as_ref(), "twist", &twist, &twist_options), ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)), @@ -249,10 +259,12 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { helix.axis_base_point = vector(base); helix.start_point = vector(point(helix.start_point) + delta); } - "height" if number.abs() > EPSILON => { + "height" => { + if number < 0.0 { helix.axis_vector = vector(-point(helix.axis_vector)); } + let number = number.abs(); if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { let turns = number / helix.turn_height; - if turns <= EPSILON { + if turns < 0.0 { return; } helix.turns = turns; @@ -260,16 +272,18 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { helix.turn_height = number / helix.turns.max(EPSILON); } } - "turns" if number > EPSILON => { - if helix.constraint == HelixConstraint::TurnHeight { + "turns" if number >= 0.0 => { + if number == 0.0 { helix.turns = 0.0; } + else if helix.constraint == HelixConstraint::TurnHeight { helix.turns = number; } else { helix.turns = number; helix.turn_height = old_height / number; } } - "turn_height" if number.abs() > EPSILON => { - if helix.constraint == HelixConstraint::Turns { + "turn_height" if number >= 0.0 => { + if number == 0.0 { helix.turn_height = 0.0; } + else if helix.constraint == HelixConstraint::Turns { helix.turn_height = number; } else { let turns = old_height / number; @@ -280,12 +294,13 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { helix.turns = turns; } } - "base_radius" if number > EPSILON => { + "base_radius" if number >= 0.0 => { let Some((base, _, start_direction)) = axis_frame(helix) else { return; }; - helix.radius = number; helix.start_point = vector(base + start_direction * number); + if !rebuild_with_radial(helix, top, Some(start_direction)) { *helix = original; } + return; } "top_radius" if number >= 0.0 => { if !rebuild(helix, number) { From 5d5aff0dd23bf07e9671b2283194968ca02c3bf0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:11:07 +0300 Subject: [PATCH 84/99] Store joined spline degree and control-vertex representation --- src/modules/draw/modify/join.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index dab1904e4..835826b57 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -118,9 +118,11 @@ pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) }?; let joined=cadkernel::space::source_join::join_nurbs_curves(&a,&b,JOIN_EPS)?; let mut spline=source.clone(); + spline.degree=joined.degree() as i32; spline.control_points=joined.control_points().iter().map(|p|Vector3::new(p[0],p[1],p[2])).collect(); spline.knots=joined.knots().to_vec();spline.weights=joined.weights().to_vec(); - spline.fit_points.clear();spline.flags.rational=true; + spline.fit_points.clear();spline.flags.rational=joined.is_rational(); + spline.dwg_flags1 &= !1;spline.dxf_flags &= !32; spline.flags.planar=cadkernel::space::are_coplanar(joined.control_points(),&[]); spline.begin_tangent=Vector3::new(0.0,0.0,0.0);spline.end_tangent=Vector3::new(0.0,0.0,0.0); Some(EntityType::Spline(spline)) From 5500272f314c6d93091c894235a3cb17bc8a8ef9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:17:59 +0300 Subject: [PATCH 85/99] Allocate distinct handles for polyline break fragments --- src/app/command_driver.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 4662f0bb6..aed88b26a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3255,7 +3255,10 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "PEDIT"); let source = pieces.remove(0); self.tabs[i].scene.update_entity(source); - for piece in pieces { self.tabs[i].scene.add_entity(piece); } + for mut piece in pieces { + piece.common_mut().handle = Handle::NULL; + self.tabs[i].scene.add_entity(piece); + } let updated = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(command) = self.tabs[i].active_cmd.as_mut() { if let Some(entity) = updated { command.inject_picked_entity(entity); } From e84d491f6a3a532e39e7c5bd7b423a752f14d281 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:18:50 +0300 Subject: [PATCH 86/99] Preserve region coplanarity rejection diagnostics --- src/app/commands/draw.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index fdce2b7d4..3889a95d1 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1153,7 +1153,9 @@ impl OpenCADStudio { .filter(|(handle, _)| !sources.contains(handle)) .filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity)) .collect(); - if let Some(plane) = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6) { + let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6); + let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none(); + if let Some(plane) = open_plane { let working_plane = crate::command::WorkingPlane::new( glam::DVec3::from_array(plane.origin), glam::DVec3::from_array(plane.x_axis), @@ -1184,12 +1186,14 @@ impl OpenCADStudio { regions.push((region, body)); sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); } - } else if !open_curves.is_empty() { - self.command_line.push_error("REGION: open objects must form coplanar, noncollinear boundaries."); + } else if open_plane_rejected { + self.command_line.push_error("Open objects must be coplanar."); } if regions.is_empty() { - self.command_line - .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); + if !open_plane_rejected { + self.command_line + .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); + } } else { self.push_undo_snapshot(i, "REGION"); let count = regions.len(); From 4a1dddfbe5f5852901671185b58caa79ae21e0d2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 12:25:24 +0300 Subject: [PATCH 87/99] Test wipeout and break command interactions --- src/modules/draw/draw/wipeout.rs | 31 +++++++++++++++++++++++++++- src/modules/draw/modify/break_cmd.rs | 25 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index 1ea1c4d26..23c4e568e 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -311,7 +311,7 @@ impl CadCommand for WipeoutCommand { } fn on_undo_step(&mut self) -> Option { - if self.mode == WipeoutMode::Draw && !self.points.is_empty() { + if self.mode == WipeoutMode::Draw && self.points.len() >= 2 { Some(self.undo_point()) } else { None @@ -484,3 +484,32 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["WIPEOUT"] }); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::LwPolyline; + + #[test] + fn command_undo_never_removes_the_first_point() { + let mut command = WipeoutCommand::new_polygonal(1); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(command.on_undo_step().is_none()); + + assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint)); + assert!(matches!(command.on_undo_step(), Some(CmdResult::NeedPoint))); + assert_eq!(command.points, vec![DVec3::ZERO]); + } + + #[test] + fn wide_polyline_is_not_accepted_as_a_wipeout_boundary() { + let mut polyline = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(2.0, 0.0), + Vector2::new(0.0, 2.0), + ]); + polyline.is_closed = true; + polyline.constant_width = 1.0; + assert!(wipeout_from_polyline(&EntityType::LwPolyline(polyline)).is_none()); + } +} diff --git a/src/modules/draw/modify/break_cmd.rs b/src/modules/draw/modify/break_cmd.rs index 0ad841512..5f00bdccd 100644 --- a/src/modules/draw/modify/break_cmd.rs +++ b/src/modules/draw/modify/break_cmd.rs @@ -502,3 +502,28 @@ impl CadCommand for BreakAtPointCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["BREAKATPOINT"] }); // BreakAtPointCommand inventory::submit!(crate::command::CommandRegistration { names: &["BREAK"] }); // BreakInteractiveCommand + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_option_replaces_the_selection_point_and_at_reuses_it() { + let handle = Handle::new(7); + let mut command = BreakInteractiveCommand::new(); + assert!(matches!( + command.on_entity_pick(handle, DVec3::new(1.0, 2.0, 0.0)), + CmdResult::NeedPoint + )); + assert_eq!(command.options().len(), 1); + assert!(matches!(command.on_text_input("F"), Some(CmdResult::NeedPoint))); + + let replacement = DVec3::new(3.0, 4.0, 0.0); + assert!(matches!(command.on_point(replacement), CmdResult::NeedPoint)); + assert!(matches!( + command.on_text_input("@"), + Some(CmdResult::BreakEntity { handle: result, p1, p2 }) + if result == handle && p1 == replacement && p2 == replacement + )); + } +} From 7181770c73ad080c4b84ec965919423b19e81f6a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:33:35 +0300 Subject: [PATCH 88/99] Elevate spatial spline order through the NURBS kernel --- src/modules/draw/modify/splinedit.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 5e260b414..b4ca5387f 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -105,6 +105,29 @@ impl SplineditCommand { fn refined(&self, point: Option, degree: Option) -> Option { let source = self.spline.as_ref()?; + if let Some(degree) = degree { + let current = usize::try_from(source.degree).ok()?; + if degree <= current || degree > 25 { return None; } + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; + let curve = cadkernel::space::NurbsCurve3::new_strict(current, + source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), + source.knots.clone(), weights)? + .with_periodicity(source.flags.closed || source.flags.periodic) + .elevated(degree - current)?; + let mut result = source.clone(); + result.degree = curve.degree() as i32; + result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + result.knots = curve.knots().to_vec(); + result.weights = curve.weights().to_vec(); + result.fit_points.clear(); + result.begin_tangent = Vector3::ZERO; + result.end_tangent = Vector3::ZERO; + result.dwg_flags1 &= !1; + result.dxf_flags &= !(32 | 1024); + result.flags.rational = curve.is_rational(); + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + return Some(result); + } let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; let cadkernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else { return None; }; if let Some(point) = point { @@ -115,10 +138,6 @@ impl SplineditCommand { if parameter <= start || parameter >= end { return None; } curve.insert_knot(parameter); } - if let Some(degree) = degree { - if degree <= curve.degree() || degree > 25 { return None; } - curve = curve.elevated(degree - curve.degree())?; - } let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); result.control_points = curve.control_points().iter().map(|point| { let world = planar.plane.point_at(*point); From ed6e962b7850064a49fd7ce6dd9302127dd153e4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:33:49 +0300 Subject: [PATCH 89/99] Compact redundant polynomial knots after order elevation --- src/modules/draw/modify/splinedit.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index b4ca5387f..7f164aec7 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -113,7 +113,8 @@ impl SplineditCommand { source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), source.knots.clone(), weights)? .with_periodicity(source.flags.closed || source.flags.periodic) - .elevated(degree - current)?; + .elevated(degree - current)? + .compact_knots(source.control_tolerance.max(1e-9))?; let mut result = source.clone(); result.degree = curve.degree() as i32; result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); From 29cd7fa82b449e4fee35cfebe1fa739a82ddc40b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:35:34 +0300 Subject: [PATCH 90/99] Resolve fit-only spatial splines before order elevation --- src/modules/draw/modify/splinedit.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 7f164aec7..f18ce5920 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -109,10 +109,24 @@ impl SplineditCommand { let current = usize::try_from(source.degree).ok()?; if degree <= current || degree > 25 { return None; } let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; - let curve = cadkernel::space::NurbsCurve3::new_strict(current, - source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), - source.knots.clone(), weights)? - .with_periodicity(source.flags.closed || source.flags.periodic) + let curve = if source.control_points.is_empty() && source.fit_points.len() >= 2 { + use cadkernel::space::{NurbsCurve3, Parameterization}; + let points: Vec<_> = source.fit_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0).then_some([point.x, point.y, point.z]); + if source.flags.closed || source.flags.periodic { + NurbsCurve3::interpolate_periodic(&points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&points, tangent(&source.begin_tangent), tangent(&source.end_tangent), parameterization)? + } + } else { + cadkernel::space::NurbsCurve3::new_strict(current, + source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), + source.knots.clone(), weights)? + }; + let curve = curve.with_periodicity(source.flags.closed || source.flags.periodic) .elevated(degree - current)? .compact_knots(source.control_tolerance.max(1e-9))?; let mut result = source.clone(); From deb9129e91af8a649eb8217233559bc4eb3c9576 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:52:07 +0300 Subject: [PATCH 91/99] Convert splines to bounded straight-segment polylines --- src/app/commands/inquiry.rs | 2 +- src/modules/draw/modify/splinedit.rs | 97 +++++++++++++++++++++------- 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 7d98772cd..2d2a5c024 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -329,7 +329,7 @@ impl OpenCADStudio { "SPLINEDIT" => { use crate::modules::draw::modify::splinedit::SplineditCommand; - let mut cmd_obj = SplineditCommand::new(); + let mut cmd_obj = SplineditCommand::new().with_delete_source(self.delete_objects != 0); let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); if let [handle] = selected.as_slice() { if let Some(entity @ acadrust::EntityType::Spline(_)) = diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index f18ce5920..113f0b820 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -34,6 +34,7 @@ enum Step { Options, Refine, Join, + PolylinePrecision, Add, Delete, Elevate, @@ -50,11 +51,49 @@ pub struct SplineditCommand { history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, pick_context: Option, join_candidates: Vec, + delete_source: bool, } impl SplineditCommand { pub fn new() -> Self { - Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new() } + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new(), delete_source: true } + } + + pub fn with_delete_source(mut self, delete: bool) -> Self { self.delete_source = delete; self } + + fn convert_polyline(&mut self, precision: u8) -> CmdResult { + let result = self.spline.as_ref().and_then(|source| { + let curve = spatial_spline(source)?; + let approximation = curve.to_polyline_precision(precision)?; + let mut points = approximation.points; + if self.closed() { + if points.first() != points.last() { return None; } + points.pop(); + } + if points.len() < 2 { return None; } + let entity = if let Some(planar) = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone())) { + let normal = planar.plane.normal()?; + let elevation = cadkernel::space::Vec3::from(points[0]).dot(cadkernel::space::Vec3::from(normal)); + let normal = Vector3::new(normal[0], normal[1], normal[2]); + let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation); + let mut polyline = acadrust::LwPolyline::new(); + polyline.common = source.common.clone(); + polyline.elevation = elevation; polyline.normal = normal; polyline.is_closed = self.closed(); + polyline.vertices = points.iter().map(|point| { + let uv = plane.project(*point)?; + Some(acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(uv[0], uv[1]))) + }).collect::>>()?; + EntityType::LwPolyline(polyline) + } else { + let mut polyline = acadrust::entities::Polyline3D::from_points(points.iter().map(|p| Vector3::new(p[0],p[1],p[2])).collect()); + polyline.common = source.common.clone(); polyline.flags.closed = self.closed(); + EntityType::Polyline3D(polyline) + }; + Some(entity) + }); + let Some(mut entity) = result else { return CmdResult::ReportError(crate::t!("Spline cannot be converted within the requested precision.").into_owned()); }; + if self.delete_source { CmdResult::ReplaceMany(vec![(self.handle,vec![entity])], Vec::new()) } + else { entity.common_mut().handle = acadrust::Handle::NULL; CmdResult::ReplaceMany(Vec::new(), vec![entity]) } } fn picked_vertex(&self, point: DVec3) -> Option { @@ -108,24 +147,7 @@ impl SplineditCommand { if let Some(degree) = degree { let current = usize::try_from(source.degree).ok()?; if degree <= current || degree > 25 { return None; } - let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; - let curve = if source.control_points.is_empty() && source.fit_points.len() >= 2 { - use cadkernel::space::{NurbsCurve3, Parameterization}; - let points: Vec<_> = source.fit_points.iter().map(|point| [point.x, point.y, point.z]).collect(); - let parameterization = match source.knot_parameterization { - 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, - }; - let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0).then_some([point.x, point.y, point.z]); - if source.flags.closed || source.flags.periodic { - NurbsCurve3::interpolate_periodic(&points, parameterization)? - } else { - NurbsCurve3::interpolate_fit(&points, tangent(&source.begin_tangent), tangent(&source.end_tangent), parameterization)? - } - } else { - cadkernel::space::NurbsCurve3::new_strict(current, - source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), - source.knots.clone(), weights)? - }; + let curve = spatial_spline(source)?; let curve = curve.with_periodicity(source.flags.closed || source.flags.periodic) .elevated(degree - current)? .compact_knots(source.control_tolerance.max(1e-9))?; @@ -167,8 +189,9 @@ impl CadCommand for SplineditCommand { fn prompt(&self) -> String { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), - Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").into_owned(), Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), @@ -185,7 +208,7 @@ impl CadCommand for SplineditCommand { Step::Options => { let mut options = vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }]; if !self.closed() { options.push(CmdOption::new("Join", "J")); } - options.extend([CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")]); + options.extend([CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Polyline (lines)", "P"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")]); options }, Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], @@ -227,6 +250,7 @@ impl CadCommand for SplineditCommand { if upper.is_empty() { return Some(self.on_enter()); } match self.step { Step::Options => match upper.as_str() { + "P" | "POLYLINE" => self.step = Step::PolylinePrecision, "J" | "JOIN" if !self.closed() => { self.join_candidates.clear(); self.step = Step::Join; }, "R" | "REFINE" => self.step = Step::Refine, "M" | "MOVE" => self.step = Step::Move { index: 0, refine: false }, @@ -251,6 +275,11 @@ impl CadCommand for SplineditCommand { } _ => {} }, + Step::PolylinePrecision => { + let Ok(precision) = upper.parse::() else { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); }; + if precision > 99 { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); } + return Some(self.convert_polyline(precision)); + } Step::Refine => match upper.as_str() { "A" | "ADD" => self.step = Step::Add, "D" | "DELETE" => self.step = Step::Delete, @@ -348,6 +377,7 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline | Step::Options => CmdResult::Cancel, Step::Join => self.finish_join(), + Step::PolylinePrecision => self.convert_polyline(10), Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { @@ -358,7 +388,7 @@ impl CadCommand for SplineditCommand { } } fn on_escape(&mut self) -> CmdResult { - if matches!(self.step, Step::Join) { + if matches!(self.step, Step::Join | Step::PolylinePrecision) { self.join_candidates.clear(); self.step = Step::Options; CmdResult::NeedPoint @@ -445,3 +475,26 @@ fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option Option { + let current = usize::try_from(source.degree).ok()?; + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; + let curve = if source.control_points.is_empty() && source.fit_points.len() >= 2 { + use cadkernel::space::{NurbsCurve3, Parameterization}; + let points: Vec<_> = source.fit_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0).then_some([point.x, point.y, point.z]); + if source.flags.closed || source.flags.periodic { + NurbsCurve3::interpolate_periodic(&points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&points, tangent(&source.begin_tangent), tangent(&source.end_tangent), parameterization)? + } + } else { + cadkernel::space::NurbsCurve3::new_strict(current, + source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), + source.knots.clone(), weights)? + }; + Some(curve.with_periodicity(source.flags.closed || source.flags.periodic)) +} From 356c8b5a7b5549458620ec98e0d05ebe44976902 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:57:58 +0300 Subject: [PATCH 92/99] Keep multiple polyline editing active across selected joins --- src/app/command_driver.rs | 30 ++++++++++++++++++++++++++++++ src/modules/draw/modify/pedit.rs | 21 +++++++++++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index aed88b26a..0017ad2b2 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3216,6 +3216,36 @@ impl OpenCADStudio { apply_pedit, convert_to_polyline, PeditOp, }; match &op { + PeditOp::JoinSelection(handles) => { + let mut available = handles.iter().filter_map(|handle| { + if self.tabs[i].scene.is_layer_locked(*handle) { return None; } + self.tabs[i].scene.document.get_entity(*handle).cloned().map(|entity| (*handle, entity)) + }).collect::>(); + let mut changes = Vec::new(); + while !available.is_empty() { + let (source_handle, source) = available.remove(0); + let candidates = available.iter().map(|(handle, entity)| (*handle, entity)).collect::>(); + if let Some((mut result, consumed)) = crate::modules::draw::modify::join::join_to_source(&source, &candidates) { + *result.common_mut() = source.common().clone(); + available.retain(|(handle, _)| !consumed.contains(handle)); + changes.push((source_handle, result, consumed)); + } + } + if !changes.is_empty() { + self.push_undo_snapshot(i, "PEDIT"); + for (source, replacement, consumed) in changes { + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.update_entity(replacement.clone()); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + for old in consumed { command.on_entity_replaced(old, &[source]); } + command.inject_picked_entity(replacement); + } + } + if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_pedit_applied(); } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + } PeditOp::Multiple(handles, operation) => { let replacements: Vec<_> = handles.iter().filter(|handle| !self.tabs[i].scene.is_layer_locked(**handle)).filter_map(|handle| { let original = self.tabs[i].scene.document.get_entity(*handle)?; diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 4450f0313..963d9d350 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -31,6 +31,7 @@ enum Mode { PickTarget, MultipleGather, MultipleConvert, + MultipleJoin, Options, /// Picked a Line/Arc: asking "Turn it into one? [Yes/No]". ConvertPrompt(Handle), @@ -234,6 +235,7 @@ impl CadCommand for PeditCommand { t!("PEDIT Select polyline (or a line/arc to convert) or [Multiple]:").into_owned() } Mode::MultipleGather => format!("PEDIT Select objects ({} selected, Enter when done):", self.multiple.len()), + Mode::MultipleJoin => "PEDIT Enter fuzz distance <0>:".to_string(), Mode::MultipleConvert => t!("PEDIT Convert lines and arcs to polylines [Yes/No] :").into_owned(), Mode::ConvertPrompt(_) => t!( "PEDIT Object is not a polyline. Turn it into one? [Yes/No] :" @@ -392,6 +394,8 @@ impl CadCommand for PeditCommand { mesh_closed: None, }, ); + let mut seen = std::collections::HashSet::new(); + self.multiple.retain(|handle| seen.insert(*handle)); self.target = Some(nh); self.mode = Mode::Options; } @@ -433,6 +437,13 @@ impl CadCommand for PeditCommand { if matches!(up.as_str(), "M" | "MULTIPLE") { self.mode = Mode::MultipleGather; Some(CmdResult::NeedPoint) } else { None } } Mode::MultipleGather => None, + Mode::MultipleJoin => { + let fuzz = text.trim().parse::().ok()?; + if fuzz != 0.0 { return Some(CmdResult::ReportError("PEDIT: only zero fuzz distance is currently supported.".to_string())); } + self.pending_multiple = Some(self.multiple.clone()); + self.mode = Mode::Options; + return Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::JoinSelection(self.multiple.clone()) }); + }, Mode::MultipleConvert => { match up.as_str() { "Y" | "YES" | "" => { @@ -443,8 +454,8 @@ impl CadCommand for PeditCommand { "N" | "NO" => { self.multiple.retain(|handle| self.info.get(&handle.value()).is_some_and(|info| info.is_poly)); self.target = self.multiple.first().copied(); - self.mode = Mode::Options; - Some(if self.target.is_some() { CmdResult::NeedPoint } else { CmdResult::Cancel }) + self.mode = if self.target.is_some() { Mode::Options } else { Mode::PickTarget }; + Some(CmdResult::NeedPoint) } _ => Some(CmdResult::NeedPoint), } @@ -665,7 +676,7 @@ impl CadCommand for PeditCommand { Some(CmdResult::NeedPoint) } "J" | "JOIN" => { - self.mode = Mode::JoinGather(vec![handle]); + self.mode = if self.multiple.is_empty() { Mode::JoinGather(vec![handle]) } else { Mode::MultipleJoin }; Some(CmdResult::NeedPoint) } "F" | "FIT" => Some(CmdResult::PeditOp { @@ -751,6 +762,7 @@ impl CadCommand for PeditCommand { CmdResult::NeedPoint } Mode::MultipleConvert => self.on_text_input("Y").unwrap_or(CmdResult::NeedPoint), + Mode::MultipleJoin => self.on_text_input("0").unwrap_or(CmdResult::NeedPoint), Mode::PolyWidthStart(_, width) | Mode::PolyWidthEnd(_, width) => { let value = width.to_string(); self.on_text_input(&value).unwrap_or(CmdResult::NeedPoint) } Mode::PolyVertex(_) | Mode::PolyRange { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), Mode::PolyMove(_) | Mode::PolyInsert(_) => { self.mode = Mode::Options; CmdResult::NeedPoint } @@ -788,6 +800,7 @@ impl CadCommand for PeditCommand { #[derive(Clone)] pub enum PeditOp { Multiple(Vec, Box), + JoinSelection(Vec), SetClosed(bool), SetWidth(f64), SetVertexWidth { index: usize, start: f64, end: f64 }, @@ -853,7 +866,7 @@ pub fn edit_vertex_range(entity: &EntityType, first: usize, last: usize, split: pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { - PeditOp::Multiple(_, _) | PeditOp::VertexRange { .. } => false, + PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_) | PeditOp::VertexRange { .. } => false, PeditOp::SetVertexWidth { index, start, end } => { if !start.is_finite() || !end.is_finite() || *start < 0.0 || *end < 0.0 { return false; } match entity { From 7db1bfb1134ba68922fa9ef4742a54222f42e27c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 13:02:17 +0300 Subject: [PATCH 93/99] Update cadkernel for spline editing --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2bcc821e..c38ab3d34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b414090f1b54191242d2f880cfb1fa08050698bd#b414090f1b54191242d2f880cfb1fa08050698bd" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2c189defcfd7dad9499746470abb7380fdef0e20#2c189defcfd7dad9499746470abb7380fdef0e20" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b414090f1b54191242d2f880cfb1fa08050698bd#b414090f1b54191242d2f880cfb1fa08050698bd" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2c189defcfd7dad9499746470abb7380fdef0e20#2c189defcfd7dad9499746470abb7380fdef0e20" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index ff3e437a1..d63889d5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b414090f1b54191242d2f880cfb1fa08050698bd", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b414090f1b54191242d2f880cfb1fa08050698bd" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2c189defcfd7dad9499746470abb7380fdef0e20", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2c189defcfd7dad9499746470abb7380fdef0e20" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From e875b2ec5d1b54d95e7d934fa8cecd71bc58f336 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:03:34 +0300 Subject: [PATCH 94/99] Resolve fit-only source and candidate splines for joining --- src/modules/draw/modify/join.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 835826b57..672b71762 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -107,6 +107,17 @@ pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) (EntityType::Spline(source), candidate) => { let curve = |s:&acadrust::entities::Spline| { if s.flags.closed || s.flags.periodic {return None;} + if s.control_points.is_empty() && s.fit_points.len() >= 2 { + use cadkernel::space::{NurbsCurve3, Parameterization}; + let points: Vec<_> = s.fit_points.iter().map(|point| [point.x,point.y,point.z]).collect(); + let parameterization = match s.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0) + .then_some([point.x,point.y,point.z]); + return NurbsCurve3::interpolate_fit(&points,tangent(&s.begin_tangent),tangent(&s.end_tangent),parameterization)? + .compact_knots(s.control_tolerance.max(1e-9)); + } let weights=if s.weights.is_empty(){vec![1.0;s.control_points.len()]}else{s.weights.clone()}; cadkernel::space::NurbsCurve3::new_strict(s.degree as usize,s.control_points.iter().map(|p|[p.x,p.y,p.z]).collect(),s.knots.clone(),weights) }; From da34032ffc75b64d608c880e07abc3908ee4804d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 13:23:00 +0300 Subject: [PATCH 95/99] Harden spline editing and source joins --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/commands/styleprops.rs | 5 +- src/modules/draw/modify/join.rs | 93 ++++++++++- src/modules/draw/modify/splinedit.rs | 232 +++++++++++++++++++++++++-- 5 files changed, 316 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c38ab3d34..d894754b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2c189defcfd7dad9499746470abb7380fdef0e20#2c189defcfd7dad9499746470abb7380fdef0e20" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=979e668a2a81ef28147b694510b05f6e87bb96c5#979e668a2a81ef28147b694510b05f6e87bb96c5" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2c189defcfd7dad9499746470abb7380fdef0e20#2c189defcfd7dad9499746470abb7380fdef0e20" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=979e668a2a81ef28147b694510b05f6e87bb96c5#979e668a2a81ef28147b694510b05f6e87bb96c5" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index d63889d5a..1046cf65a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2c189defcfd7dad9499746470abb7380fdef0e20", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2c189defcfd7dad9499746470abb7380fdef0e20" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "979e668a2a81ef28147b694510b05f6e87bb96c5", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "979e668a2a81ef28147b694510b05f6e87bb96c5" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 12264caa1..9f43c15ad 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -3111,12 +3111,11 @@ mod tests { for command in ["SETBYLAYER", "OVERKILL"] { let mut app = fresh_app(); let _ = app.run_command_line(command); - assert_eq!( + assert!( app.tabs[app.active_tab] .active_cmd .as_ref() - .map(|active| active.name()), - Some("SELECT"), + .is_some_and(|active| active.is_selection_gathering()), "{command} should gather an explicit selection" ); } diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 835826b57..f49d9e495 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -27,6 +27,7 @@ impl JoinCommand { pub fn with_source(mut self, handle: Handle, entity: EntityType) -> Self { self.picked = Some(entity); self.on_entity_pick(handle, DVec3::ZERO); self } + fn has_arc_source(&self) -> bool { matches!(&self.source, Some((_, EntityType::Arc(_)))) } } impl CadCommand for JoinCommand { fn name(&self) -> &'static str { "JOIN" } @@ -42,6 +43,8 @@ impl CadCommand for JoinCommand { fn options(&self) -> Vec { if matches!(&self.source,Some((_,EntityType::Arc(_)))) { vec![crate::command::CmdOption::new("Close","L")] } else {Vec::new()} } + fn wants_text_input(&self) -> bool { self.has_arc_source() } + fn point_step_accepts_keywords(&self) -> bool { self.has_arc_source() } fn on_text_input(&mut self,text:&str) -> Option { if matches!(text.trim().to_ascii_uppercase().as_str(),"L"|"CLOSE") { if let Some((handle,EntityType::Arc(arc)))=&self.source { @@ -122,7 +125,7 @@ pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) spline.control_points=joined.control_points().iter().map(|p|Vector3::new(p[0],p[1],p[2])).collect(); spline.knots=joined.knots().to_vec();spline.weights=joined.weights().to_vec(); spline.fit_points.clear();spline.flags.rational=joined.is_rational(); - spline.dwg_flags1 &= !1;spline.dxf_flags &= !32; + spline.dwg_flags1 &= !1;spline.dxf_flags &= !(32 | 1024); spline.flags.planar=cadkernel::space::are_coplanar(joined.control_points(),&[]); spline.begin_tangent=Vector3::new(0.0,0.0,0.0);spline.end_tangent=Vector3::new(0.0,0.0,0.0); Some(EntityType::Spline(spline)) @@ -511,6 +514,94 @@ mod join_tests { l.thickness ); } + + #[test] + fn source_join_consumes_only_compatible_lines_and_keeps_direction() { + let source_handle = Handle::new(11); + let rejected_handle = Handle::new(12); + let joined_handle = Handle::new(13); + let mut source = line(5.0, 0.0, 1.0, 0.0, 2.0); + source.common_mut().handle = source_handle; + let rejected = line(1.0, 0.0, 1.0, 2.0, 0.0); + let joined = line(8.0, 0.0, 6.0, 0.0, 0.0); + let (result, consumed) = join_to_source( + &source, + &[(rejected_handle, &rejected), (joined_handle, &joined)], + ) + .unwrap(); + assert_eq!(consumed, vec![joined_handle]); + let EntityType::Line(result) = result else { + panic!("expected a line"); + }; + assert_eq!(result.common.handle, source_handle); + assert_eq!(result.start, Vector3::new(8.0, 0.0, 0.0)); + assert_eq!(result.end, Vector3::new(1.0, 0.0, 0.0)); + assert_eq!(result.thickness, 2.0); + } + + #[test] + fn source_join_elevates_splines_and_clears_fit_representation() { + let source_handle = Handle::new(21); + let candidate_handle = Handle::new(22); + let mut source = acadrust::entities::Spline::new(); + source.common.handle = source_handle; + source.degree = 1; + source.control_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ]; + source.knots = vec![0.0, 0.0, 1.0, 1.0]; + source.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)]; + source.dwg_flags1 = 1; + source.dxf_flags = 32 | 1024; + let mut candidate = acadrust::entities::Spline::new(); + candidate.degree = 3; + candidate.control_points = vec![ + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(1.3, 0.2, 0.0), + Vector3::new(1.7, 0.8, 0.0), + Vector3::new(2.0, 1.0, 0.0), + ]; + candidate.knots = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]; + let candidate_entity = EntityType::Spline(candidate); + let (result, consumed) = join_to_source( + &EntityType::Spline(source), + &[(candidate_handle, &candidate_entity)], + ) + .unwrap(); + assert_eq!(consumed, vec![candidate_handle]); + let EntityType::Spline(result) = result else { + panic!("expected a spline"); + }; + assert_eq!(result.common.handle, source_handle); + assert_eq!(result.degree, 3); + assert!(result.fit_points.is_empty()); + assert_eq!(result.dwg_flags1 & 1, 0); + assert_eq!(result.dxf_flags & (32 | 1024), 0); + assert!(result.flags.planar); + } + + #[test] + fn arc_source_accepts_the_typed_close_option() { + let handle = Handle::new(31); + let mut arc = acadrust::entities::Arc::new(); + arc.common.handle = handle; + arc.center = Vector3::new(2.0, 3.0, 4.0); + arc.radius = 5.0; + arc.thickness = 0.75; + let mut command = JoinCommand::new().with_source(handle, EntityType::Arc(arc)); + assert!(command.input_kind().wants_text()); + assert!(command.point_step_accepts_keywords()); + let Some(CmdResult::ReplaceMany(mut replacements, _)) = command.on_text_input("Close") else { + panic!("expected the arc to close"); + }; + let Some(EntityType::Circle(circle)) = replacements[0].1.pop() else { + panic!("expected a circle"); + }; + assert_eq!(circle.center, Vector3::new(2.0, 3.0, 4.0)); + assert_eq!(circle.radius, 5.0); + assert_eq!(circle.thickness, 0.75); + } } /// True when every vertex lies on one straight line (within tolerance). diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index f18ce5920..cd7bf22d0 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -52,6 +52,22 @@ pub struct SplineditCommand { join_candidates: Vec, } +fn clear_fit_method(spline: &mut acadrust::entities::Spline) { + spline.fit_points.clear(); + spline.begin_tangent = Vector3::ZERO; + spline.end_tangent = Vector3::ZERO; + spline.dwg_flags1 &= !1; + spline.dxf_flags &= !(32 | 1024); +} + +fn weights_are_rational(weights: &[f64]) -> bool { + weights.first().is_some_and(|first| { + weights + .iter() + .any(|weight| (*weight - *first).abs() > 1e-12) + }) +} + impl SplineditCommand { pub fn new() -> Self { Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new() } @@ -126,19 +142,16 @@ impl SplineditCommand { source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), source.knots.clone(), weights)? }; - let curve = curve.with_periodicity(source.flags.closed || source.flags.periodic) - .elevated(degree - current)? + let curve = curve.with_periodicity(source.flags.closed || source.flags.periodic); + let elevation = degree.checked_sub(curve.degree())?; + let curve = if elevation == 0 { curve } else { curve.elevated(elevation)? } .compact_knots(source.control_tolerance.max(1e-9))?; let mut result = source.clone(); result.degree = curve.degree() as i32; result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); result.knots = curve.knots().to_vec(); result.weights = curve.weights().to_vec(); - result.fit_points.clear(); - result.begin_tangent = Vector3::ZERO; - result.end_tangent = Vector3::ZERO; - result.dwg_flags1 &= !1; - result.dxf_flags &= !(32 | 1024); + clear_fit_method(&mut result); result.flags.rational = curve.is_rational(); result.flags.planar = crate::entities::curve::spline_is_planar(&result); return Some(result); @@ -158,6 +171,9 @@ impl SplineditCommand { let world = planar.plane.point_at(*point); Vector3::new(world[0], world[1], world[2]) }).collect(); + clear_fit_method(&mut result); + result.flags.rational = curve.is_rational(); + result.flags.planar = crate::entities::curve::spline_is_planar(&result); Some(result) } } @@ -262,7 +278,10 @@ impl CadCommand for SplineditCommand { }, Step::Elevate => { let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1; - let order = upper.parse::().ok().filter(|order| *order >= current_order && *order <= 26)?; + let Some(order) = upper.parse::().ok() + .filter(|order| *order >= current_order && *order <= 26) else { + return Some(CmdResult::NeedPoint); + }; if order == current_order { self.step = Step::Refine; return Some(CmdResult::NeedPoint); @@ -290,8 +309,8 @@ impl CadCommand for SplineditCommand { let mut spline = self.spline.clone()?; spline.weights.resize(count, 1.0); spline.weights[index] = weight; - spline.flags.rational = true; - spline.fit_points.clear(); + spline.flags.rational = weights_are_rational(&spline.weights); + clear_fit_method(&mut spline); return Some(self.replace(spline)); } } @@ -329,7 +348,8 @@ impl CadCommand for SplineditCommand { spline.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); spline.weights = curve.weights().to_vec(); spline.knots = curve.knots().to_vec(); - spline.fit_points.clear(); + clear_fit_method(&mut spline); + spline.flags.rational = curve.is_rational(); spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); self.replace(spline) } @@ -338,7 +358,8 @@ impl CadCommand for SplineditCommand { let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; }; let Some(vertex) = spline.control_points.get_mut(index) else { return CmdResult::NeedPoint; }; *vertex = Vector3::new(point.x, point.y, point.z); - spline.fit_points.clear(); + clear_fit_method(&mut spline); + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); self.replace(spline) } _ => CmdResult::NeedPoint, @@ -418,7 +439,7 @@ fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option Option Option Spline { + let mut spline = Spline::new(); + spline.degree = degree as i32; + spline.control_points = points + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + spline.knots = cadkernel::space::clamped_uniform_knots(degree, points.len()); + spline + } + + fn selected_command(spline: Spline) -> SplineditCommand { + let mut command = SplineditCommand::new(); + command.inject_picked_entity(EntityType::Spline(spline)); + command.on_entity_pick(Handle::new(7), DVec3::ZERO); + command + } + + fn replacement(result: CmdResult) -> Spline { + let CmdResult::ReplaceManyContinue(mut replacements) = result else { + panic!("expected a continuing replacement"); + }; + let Some(EntityType::Spline(spline)) = replacements[0].1.pop() else { + panic!("expected a spline replacement"); + }; + spline + } + + #[test] + fn moving_a_control_vertex_clears_fit_state_and_recomputes_planarity() { + let mut spline = control_spline( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 1.0, 0.0], + [3.0, 0.0, 0.0], + ], + 3, + ); + spline.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)]; + spline.begin_tangent = Vector3::new(1.0, 0.0, 0.0); + spline.end_tangent = Vector3::new(1.0, 0.0, 0.0); + spline.dwg_flags1 = 1; + spline.dxf_flags = 32 | 1024; + spline.flags.planar = true; + let mut command = selected_command(spline); + command.on_text_input("MOVE"); + let moved = replacement(command.on_point(DVec3::new(0.0, 0.0, 1.0))); + assert!(moved.fit_points.is_empty()); + assert_eq!(moved.begin_tangent, Vector3::ZERO); + assert_eq!(moved.end_tangent, Vector3::ZERO); + assert_eq!(moved.dwg_flags1 & 1, 0); + assert_eq!(moved.dxf_flags & (32 | 1024), 0); + assert!(!moved.flags.planar); + } + + #[test] + fn equal_control_weights_are_not_marked_rational() { + let spline = control_spline( + &[ + [0.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [2.0, 1.0, 0.0], + [3.0, 0.0, 0.0], + ], + 3, + ); + let mut command = selected_command(spline); + command.on_text_input("REFINE"); + command.on_text_input("WEIGHT"); + let weighted = replacement(command.on_text_input("1").unwrap()); + assert!(!weighted.flags.rational); + assert_eq!(weighted.weights, vec![1.0; 4]); + } + + #[test] + fn deleting_the_only_weighted_vertex_clears_rational_and_fit_flags() { + let mut spline = control_spline( + &[ + [-0.6, 0.0, 0.0], + [-0.2, 0.4, 0.0], + [0.2, 0.4, 0.0], + [0.6, 0.0, 0.0], + ], + 2, + ); + spline.weights = vec![1.0, 2.0, 1.0, 1.0]; + spline.flags.rational = true; + spline.dwg_flags1 = 1; + spline.dxf_flags = 32 | 1024; + let mut command = selected_command(spline); + command.on_text_input("REFINE"); + command.on_text_input("DELETE"); + command.pick_context = Some(crate::command::PointPickContext { + view: glam::Mat4::IDENTITY, + eye: DVec3::ZERO, + bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)), + aperture_px: 2.0, + }); + let deleted = replacement(command.on_point(DVec3::new(-0.2, 0.4, 0.0))); + assert_eq!(deleted.control_points.len(), 3); + assert!(!deleted.flags.rational); + assert_eq!(deleted.dwg_flags1 & 1, 0); + assert_eq!(deleted.dxf_flags & (32 | 1024), 0); + } + + #[test] + fn fit_only_degree_elevation_uses_the_interpolated_curve_degree() { + let mut spline = Spline::new(); + spline.degree = 1; + spline.fit_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 2.0, 0.0), + Vector3::new(3.0, 0.0, 0.0), + ]; + let mut command = selected_command(spline); + command.on_text_input("REFINE"); + command.on_text_input("ELEVATE"); + assert!(matches!(command.on_text_input("invalid"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.step, Step::Elevate)); + let elevated = replacement(command.on_text_input("5").unwrap()); + assert_eq!(elevated.degree, 4); + assert!(elevated.fit_points.is_empty()); + } + + #[test] + fn a_closed_weighted_control_curve_can_be_opened() { + let curve = cadkernel::space::NurbsCurve3::from_weighted_control_polygon( + 2, + &[ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 2.0, 1.0], + [0.0, 2.0, 0.0], + ], + &[1.0, 2.0, 0.75, 1.5], + true, + ) + .unwrap(); + let mut spline = control_spline(curve.control_points(), curve.degree()); + spline.knots = curve.knots().to_vec(); + spline.weights = curve.weights().to_vec(); + spline.flags.closed = true; + spline.flags.periodic = true; + spline.flags.rational = true; + let opened = change_closure(&spline, false).expect("closed control curve opens"); + assert!(!opened.flags.closed); + assert!(!opened.flags.periodic); + assert!(cadkernel::space::NurbsCurve3::new_strict( + opened.degree as usize, + opened + .control_points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect(), + opened.knots.clone(), + opened.weights.clone(), + ) + .is_some()); + } + + #[test] + fn control_vertex_pick_uses_the_screen_aperture() { + let spline = control_spline(&[[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]], 1); + let mut command = selected_command(spline); + command.pick_context = Some(crate::command::PointPickContext { + view: glam::Mat4::IDENTITY, + eye: DVec3::ZERO, + bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)), + aperture_px: 2.0, + }); + assert_eq!(command.picked_vertex(DVec3::new(-0.49, 0.0, 0.0)), Some(0)); + assert_eq!(command.picked_vertex(DVec3::new(0.0, 0.5, 0.0)), None); + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand From 30b6441ef9c1074c8829ce13747cd1322c5950d8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 13:33:17 +0300 Subject: [PATCH 96/99] Complete bounded spline polyline conversion --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/modules/draw/modify/splinedit.rs | 91 +++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d894754b2..e52a93f49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=979e668a2a81ef28147b694510b05f6e87bb96c5#979e668a2a81ef28147b694510b05f6e87bb96c5" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=125cfe4f956d1f16c795cc13a653cd0a3aecb9e1#125cfe4f956d1f16c795cc13a653cd0a3aecb9e1" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=979e668a2a81ef28147b694510b05f6e87bb96c5#979e668a2a81ef28147b694510b05f6e87bb96c5" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=125cfe4f956d1f16c795cc13a653cd0a3aecb9e1#125cfe4f956d1f16c795cc13a653cd0a3aecb9e1" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 1046cf65a..32d92345a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "979e668a2a81ef28147b694510b05f6e87bb96c5", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "979e668a2a81ef28147b694510b05f6e87bb96c5" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "125cfe4f956d1f16c795cc13a653cd0a3aecb9e1", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "125cfe4f956d1f16c795cc13a653cd0a3aecb9e1" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index d1191b45f..6664f3889 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -205,7 +205,7 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), - Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").into_owned(), Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), @@ -674,6 +674,95 @@ mod tests { assert_eq!(command.picked_vertex(DVec3::new(-0.49, 0.0, 0.0)), Some(0)); assert_eq!(command.picked_vertex(DVec3::new(0.0, 0.5, 0.0)), None); } + + #[test] + fn planar_polyline_conversion_honors_source_deletion_policy() { + let handle = Handle::new(41); + let mut spline = control_spline( + &[ + [0.0, 0.0, 2.0], + [1.0, 2.0, 2.0], + [3.0, 0.0, 2.0], + ], + 2, + ); + spline.common.handle = handle; + spline.flags.planar = true; + let mut command = SplineditCommand::new().with_delete_source(false); + command.inject_picked_entity(EntityType::Spline(spline)); + command.on_entity_pick(handle, DVec3::ZERO); + let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else { + panic!("expected a polyline conversion"); + }; + assert!(replacements.is_empty()); + let [EntityType::LwPolyline(polyline)] = additions.as_slice() else { + panic!("expected a lightweight polyline"); + }; + assert!(polyline.common.handle.is_null()); + assert_eq!(polyline.elevation, 2.0); + assert!(polyline.vertices.len() > 2); + } + + #[test] + fn nonplanar_spline_converts_to_a_spatial_polyline() { + let spline = control_spline( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + [2.0, 1.0, 0.0], + [3.0, 0.0, 2.0], + ], + 3, + ); + let mut command = selected_command(spline); + let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else { + panic!("expected a polyline conversion"); + }; + assert!(additions.is_empty()); + let [(_, entities)] = replacements.as_slice() else { + panic!("expected one source replacement"); + }; + let [EntityType::Polyline3D(polyline)] = entities.as_slice() else { + panic!("expected a spatial polyline"); + }; + assert!(polyline.vertices.len() > 2); + } + + #[test] + fn closed_conversion_closes_once_and_invalid_precision_retries() { + let curve = cadkernel::space::NurbsCurve3::from_control_polygon( + 2, + &[ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 2.0, 0.0], + [0.0, 2.0, 0.0], + ], + true, + ) + .unwrap(); + let mut spline = control_spline(curve.control_points(), curve.degree()); + spline.knots = curve.knots().to_vec(); + spline.weights = curve.weights().to_vec(); + spline.flags.closed = true; + spline.flags.periodic = true; + let mut command = selected_command(spline); + command.on_text_input("POLYLINE"); + assert!(matches!(command.on_text_input("100"), Some(CmdResult::ReportError(_)))); + assert!(matches!(command.step, Step::PolylinePrecision)); + let CmdResult::ReplaceMany(replacements, _) = command.on_enter() else { + panic!("expected the default conversion"); + }; + let [(_, entities)] = replacements.as_slice() else { + panic!("expected one source replacement"); + }; + let [EntityType::LwPolyline(polyline)] = entities.as_slice() else { + panic!("expected a lightweight polyline"); + }; + assert!(polyline.is_closed); + assert!(polyline.vertices.len() >= 4); + assert_ne!(polyline.vertices.first().unwrap().location, polyline.vertices.last().unwrap().location); + } } // ── Autocomplete registry ───────────────────────────────── From c3987f427d1d46f81d51471156c25a56aaa677d1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:37:03 +0300 Subject: [PATCH 97/99] List polyline conversion in open spline editing prompt --- src/modules/draw/modify/splinedit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 113f0b820..a90d3172d 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -190,7 +190,7 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), - Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").into_owned(), Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), From 462024ae6f227c8b0ec24da464a0991bd164b228 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 13:49:13 +0300 Subject: [PATCH 98/99] Update cadkernel for spatial angle bisectors --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e52a93f49..ff3c923d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=125cfe4f956d1f16c795cc13a653cd0a3aecb9e1#125cfe4f956d1f16c795cc13a653cd0a3aecb9e1" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=84933f9d2e441555a860f843f2577378edee157e#84933f9d2e441555a860f843f2577378edee157e" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=125cfe4f956d1f16c795cc13a653cd0a3aecb9e1#125cfe4f956d1f16c795cc13a653cd0a3aecb9e1" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=84933f9d2e441555a860f843f2577378edee157e#84933f9d2e441555a860f843f2577378edee157e" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 32d92345a..99cf0fe9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "125cfe4f956d1f16c795cc13a653cd0a3aecb9e1", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "125cfe4f956d1f16c795cc13a653cd0a3aecb9e1" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "84933f9d2e441555a860f843f2577378edee157e", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "84933f9d2e441555a860f843f2577378edee157e" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From aa11b10c9a60dd7761dbde56d4e675e488c69020 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:05:50 +0300 Subject: [PATCH 99/99] Extend selected polyline terminals using bounded kernel intersections --- src/app/command_driver.rs | 4 +- src/modules/draw/modify/pedit.rs | 84 +++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0017ad2b2..ebe55b0f0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3216,7 +3216,7 @@ impl OpenCADStudio { apply_pedit, convert_to_polyline, PeditOp, }; match &op { - PeditOp::JoinSelection(handles) => { + PeditOp::JoinSelection(handles, fuzz) => { let mut available = handles.iter().filter_map(|handle| { if self.tabs[i].scene.is_layer_locked(*handle) { return None; } self.tabs[i].scene.document.get_entity(*handle).cloned().map(|entity| (*handle, entity)) @@ -3225,7 +3225,7 @@ impl OpenCADStudio { while !available.is_empty() { let (source_handle, source) = available.remove(0); let candidates = available.iter().map(|(handle, entity)| (*handle, entity)).collect::>(); - if let Some((mut result, consumed)) = crate::modules::draw::modify::join::join_to_source(&source, &candidates) { + if let Some((mut result, consumed)) = crate::modules::draw::modify::pedit::join_selection_extend(&source, &candidates, *fuzz) { *result.common_mut() = source.common().clone(); available.retain(|(handle, _)| !consumed.contains(handle)); changes.push((source_handle, result, consumed)); diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 963d9d350..a021852c1 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -12,6 +12,7 @@ use crate::command::{CadCommand, CmdResult}; use crate::t; const TAU: f64 = std::f64::consts::TAU; +static JOIN_FUZZ: std::sync::Mutex = std::sync::Mutex::new(0.0); /// What PEDIT knows about a pickable entity, captured at dispatch. #[derive(Clone, Copy)] @@ -58,6 +59,7 @@ pub struct PeditCommand { multiple: Vec, multiple_history: Vec>, pending_multiple: Option>, + join_fuzz: f64, info: HashMap, mode: Mode, undo_count: usize, @@ -91,6 +93,7 @@ impl PeditCommand { multiple: Vec::new(), multiple_history: Vec::new(), pending_multiple: None, + join_fuzz: *JOIN_FUZZ.lock().unwrap_or_else(|error| error.into_inner()), info, mode: Mode::PickTarget, undo_count: 0, @@ -235,7 +238,7 @@ impl CadCommand for PeditCommand { t!("PEDIT Select polyline (or a line/arc to convert) or [Multiple]:").into_owned() } Mode::MultipleGather => format!("PEDIT Select objects ({} selected, Enter when done):", self.multiple.len()), - Mode::MultipleJoin => "PEDIT Enter fuzz distance <0>:".to_string(), + Mode::MultipleJoin => format!("PEDIT Join type: Extend. Enter fuzz distance <{}>:", self.join_fuzz), Mode::MultipleConvert => t!("PEDIT Convert lines and arcs to polylines [Yes/No] :").into_owned(), Mode::ConvertPrompt(_) => t!( "PEDIT Object is not a polyline. Turn it into one? [Yes/No] :" @@ -439,10 +442,12 @@ impl CadCommand for PeditCommand { Mode::MultipleGather => None, Mode::MultipleJoin => { let fuzz = text.trim().parse::().ok()?; - if fuzz != 0.0 { return Some(CmdResult::ReportError("PEDIT: only zero fuzz distance is currently supported.".to_string())); } + if !fuzz.is_finite() || fuzz < 0.0 { return Some(CmdResult::ReportError("PEDIT: fuzz distance must be nonnegative and finite.".to_string())); } + self.join_fuzz = fuzz; + *JOIN_FUZZ.lock().unwrap_or_else(|error| error.into_inner()) = fuzz; self.pending_multiple = Some(self.multiple.clone()); self.mode = Mode::Options; - return Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::JoinSelection(self.multiple.clone()) }); + return Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::JoinSelection(self.multiple.clone(), fuzz) }); }, Mode::MultipleConvert => { match up.as_str() { @@ -762,7 +767,7 @@ impl CadCommand for PeditCommand { CmdResult::NeedPoint } Mode::MultipleConvert => self.on_text_input("Y").unwrap_or(CmdResult::NeedPoint), - Mode::MultipleJoin => self.on_text_input("0").unwrap_or(CmdResult::NeedPoint), + Mode::MultipleJoin => self.on_text_input(&self.join_fuzz.to_string()).unwrap_or(CmdResult::NeedPoint), Mode::PolyWidthStart(_, width) | Mode::PolyWidthEnd(_, width) => { let value = width.to_string(); self.on_text_input(&value).unwrap_or(CmdResult::NeedPoint) } Mode::PolyVertex(_) | Mode::PolyRange { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), Mode::PolyMove(_) | Mode::PolyInsert(_) => { self.mode = Mode::Options; CmdResult::NeedPoint } @@ -800,7 +805,7 @@ impl CadCommand for PeditCommand { #[derive(Clone)] pub enum PeditOp { Multiple(Vec, Box), - JoinSelection(Vec), + JoinSelection(Vec, f64), SetClosed(bool), SetWidth(f64), SetVertexWidth { index: usize, start: f64, end: f64 }, @@ -866,7 +871,7 @@ pub fn edit_vertex_range(entity: &EntityType, first: usize, last: usize, split: pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { - PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_) | PeditOp::VertexRange { .. } => false, + PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _) | PeditOp::VertexRange { .. } => false, PeditOp::SetVertexWidth { index, start, end } => { if !start.is_finite() || !end.is_finite() || *start < 0.0 || *end < 0.0 { return false; } match entity { @@ -1318,3 +1323,70 @@ fn spline_smooth(p: &mut acadrust::LwPolyline) -> bool { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand + + +/// Extend straight terminal spans before reusing the ordinary source join. +/// Curved terminal spans and unsupported representations remain unchanged. +pub fn join_selection_extend(source: &EntityType, candidates: &[(Handle, &EntityType)], fuzz: f64) -> Option<(EntityType, Vec)> { + fn endpoint(entity: &EntityType, start: bool) -> Option<[[f64; 3]; 2]> { + let (points, normal, elevation) = match entity { + EntityType::LwPolyline(p) if !p.is_closed && p.vertices.len() >= 2 => { + let n = p.vertices.len(); let segment = if start {0} else {n-2}; + if p.vertices[segment].bulge != 0.0 { return None; } + let indices = if start {[1,0]} else {[n-2,n-1]}; + (indices.map(|i| [p.vertices[i].location.x,p.vertices[i].location.y]),p.normal.clone(),p.elevation) + } + EntityType::Polyline2D(p) if !p.is_closed() && p.vertices.len() >= 2 => { + let n = p.vertices.len(); let segment = if start {0} else {n-2}; + if p.vertices[segment].bulge != 0.0 { return None; } + let indices = if start {[1,0]} else {[n-2,n-1]}; + (indices.map(|i| [p.vertices[i].location.x,p.vertices[i].location.y]),p.normal.clone(),p.elevation) + } + _ => return None, + }; + let plane = crate::entities::curve::ocs_plane(normal,elevation); + Some(points.map(|point| plane.point_at(point))) + } + fn move_endpoint(entity: &mut EntityType, start: bool, point: [f64;3]) -> Option<()> { + match entity { + EntityType::LwPolyline(p) => { + let local = crate::entities::curve::ocs_plane(p.normal.clone(),p.elevation).project(point)?; + let index = if start {0} else {p.vertices.len().checked_sub(1)?}; + p.vertices[index].location = Vector2::new(local[0],local[1]); + } + EntityType::Polyline2D(p) => { + let local = crate::entities::curve::ocs_plane(p.normal.clone(),p.elevation).project(point)?; + let index = if start {0} else {p.vertices.len().checked_sub(1)?}; + p.vertices[index].location.x = local[0]; p.vertices[index].location.y = local[1]; + } + _ => return None, + } + Some(()) + } + let mut result = source.clone(); let mut consumed = Vec::new(); + loop { + let mut progress = false; + for (handle,candidate) in candidates { + if consumed.contains(handle) {continue;} + let mut joined = super::join::join_to_source(&result,&[(*handle,*candidate)]).map(|(entity,_)|entity); + if joined.is_none() && fuzz > 0.0 && fuzz.is_finite() { + 'ends: for a_start in [false,true] { for b_start in [true,false] { + let Some(a) = endpoint(&result,a_start) else {continue;}; + let Some(b) = endpoint(candidate,b_start) else {continue;}; + let Some(point) = cadkernel::space::endpoint_join::extend_line_ends(a,b,fuzz) else {continue;}; + let mut a = result.clone(); let mut b = (*candidate).clone(); + if move_endpoint(&mut a,a_start,point).is_none() || move_endpoint(&mut b,b_start,point).is_none() {continue;} + if let Some((entity,_)) = super::join::join_to_source(&a,&[(*handle,&b)]) { + joined = Some(entity); break 'ends; + } + }} + } + if let Some(mut entity) = joined { + *entity.common_mut() = source.common().clone(); + result = entity; consumed.push(*handle); progress = true; + } + } + if !progress {break;} + } + (!consumed.is_empty()).then_some((result,consumed)) +}