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");