Harden parametric sketch constraints

This commit is contained in:
Hakan Seven committed 2026-09-11 09:45:57 +03:00
1 parent 41c07e9858
commit 9b58ca8a93
101 files changed
+3128 -13767

No files matched your search

-6
View File
@@ -35,9 +35,3 @@ __pycache__/
# Claude Code local preview/dev-server config # Claude Code local preview/dev-server config
.claude/launch.json .claude/launch.json
# macOS Finder metadata
.DS_Store
# Local code-indexing tool cache
.understand-anything/
-147
View File
@@ -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))
Generated
+10 -9
View File
@@ -12,6 +12,7 @@ dependencies = [
"bincode", "bincode",
"bytemuck", "bytemuck",
"cadkernel", "cadkernel",
"cadkernel-constraints",
"clap", "clap",
"console_error_panic_hook", "console_error_panic_hook",
"cosmic-text 0.15.0", "cosmic-text 0.15.0",
@@ -39,7 +40,6 @@ dependencies = [
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"ocs_doc_api", "ocs_doc_api",
"ocs_gcs",
"ocs_plugin_api", "ocs_plugin_api",
"open", "open",
"printpdf", "printpdf",
@@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" 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 = [ dependencies = [
"acadrust", "acadrust",
"cavalier_contours", "cavalier_contours",
@@ -903,6 +903,14 @@ dependencies = [
"spade", "spade",
] ]
[[package]]
name = "cadkernel-constraints"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7d12f4a87c4737bb83fdad40e613468513d38904#7d12f4a87c4737bb83fdad40e613468513d38904"
dependencies = [
"nalgebra",
]
[[package]] [[package]]
name = "calloop" name = "calloop"
version = "0.13.0" version = "0.13.0"
@@ -3952,13 +3960,6 @@ dependencies = [
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
] ]
[[package]]
name = "ocs_gcs"
version = "0.1.0"
dependencies = [
"nalgebra",
]
[[package]] [[package]]
name = "ocs_plugin_api" name = "ocs_plugin_api"
version = "0.2.1" version = "0.2.1"
+2 -3
View File
@@ -12,14 +12,12 @@ members = [
"crates/dwg-thumbnailer", "crates/dwg-thumbnailer",
"crates/dwg-thumbnailer-win", "crates/dwg-thumbnailer-win",
"crates/ocs_doc_api", "crates/ocs_doc_api",
"crates/ocs_gcs",
] ]
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
winresource = "0.1" winresource = "0.1"
[dependencies] [dependencies]
ocs_gcs = { path = "crates/ocs_gcs" }
serde_json = "1" serde_json = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
base64 = "0.22" base64 = "0.22"
@@ -34,7 +32,8 @@ rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] } clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11" env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } 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" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
-32
View File
@@ -3,29 +3,6 @@
#[cfg(windows)] #[cfg(windows)]
use std::path::Path; 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() { fn main() {
let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version");
let parts: Vec<&str> = version.split('.').collect(); let parts: Vec<&str> = version.split('.').collect();
@@ -43,15 +20,6 @@ fn main() {
println!("cargo:rerun-if-changed=.git/{reference}"); 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") let revision = std::process::Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"]) .args(["rev-parse", "--short=12", "HEAD"])
.output() .output()
+1 -1
View File
@@ -15,7 +15,7 @@ thiserror = "1"
# Host adapters convert ObjectId to acadrust handles; kernel handles stay local. # 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 } 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"] } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] }
[build-dependencies] [build-dependencies]
-13
View File
@@ -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"
@@ -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<ParamId> {
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(&param) {
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<ParamId, f64>, 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<ParamId> {
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(&param) {
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(&param) {
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<ParamId, f64>, 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);
}
}
-216
View File
@@ -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<usize>`) 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<ParamId> {
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<Point> =
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);
}
}
@@ -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<ParamId> {
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<bool>,
}
impl C2CDistance {
pub fn new(c1: Circle, c2: Circle, distance: ParamId, c1_bigger: Option<bool>) -> Self {
Self { c1, c2, distance, c1_bigger }
}
fn error_grad(&self, store: &ParamStore, derivparam: Option<ParamId>) -> (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<ParamId> {
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<ParamId>) -> (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<ParamId>) -> (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<ParamId> {
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<ParamId>) -> (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<ParamId>) -> (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<ParamId> {
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<ParamId> {
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);
}
}
-424
View File
@@ -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<ParamId> {
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(&param) {
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<ParamId>) -> (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<ParamId> {
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<ParamId> {
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<ParamId>) -> (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<ParamId> {
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<ParamId> {
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(&param) {
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<ParamId>) -> (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<ParamId> {
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);
}
}
@@ -1,498 +0,0 @@
//! Constraints that hold one or more curves generically (as `Rc<dyn Curve>`)
//! 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<dyn Curve>` 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<dyn Curve>,
u: ParamId,
}
impl CurveValue {
/// `pcoord` must be `p.x` or `p.y`.
pub fn new(p: Point, pcoord: ParamId, crv: Rc<dyn Curve>, 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<ParamId>) -> 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<ParamId> {
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<dyn Curve>,
crv2: Rc<dyn Curve>,
poa: Point,
angle: ParamId,
}
impl AngleViaPoint {
pub fn new(crv1: Rc<dyn Curve>, crv2: Rc<dyn Curve>, poa: Point, angle: ParamId) -> Self {
Self { crv1, crv2, poa, angle }
}
}
impl Constraint for AngleViaPoint {
fn params(&self) -> Vec<ParamId> {
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<dyn Curve>,
crv2: Rc<dyn Curve>,
poa1: Point,
poa2: Point,
angle: ParamId,
}
impl AngleViaTwoPoints {
pub fn new(crv1: Rc<dyn Curve>, crv2: Rc<dyn Curve>, poa1: Point, poa2: Point, angle: ParamId) -> Self {
Self { crv1, crv2, poa1, poa2, angle }
}
}
impl Constraint for AngleViaTwoPoints {
fn params(&self) -> Vec<ParamId> {
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<dyn Curve>,
crv2: Rc<dyn Curve>,
poa: Point,
cparam: ParamId,
angle: ParamId,
}
impl AngleViaPointAndParam {
pub fn new(crv1: Rc<dyn Curve>, crv2: Rc<dyn Curve>, poa: Point, cparam: ParamId, angle: ParamId) -> Self {
Self { crv1, crv2, poa, cparam, angle }
}
}
impl Constraint for AngleViaPointAndParam {
fn params(&self) -> Vec<ParamId> {
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<dyn Curve>,
crv2: Rc<dyn Curve>,
point: Point,
cparam1: ParamId,
cparam2: ParamId,
angle: ParamId,
}
impl AngleViaPointAndTwoParams {
pub fn new(
crv1: Rc<dyn Curve>,
crv2: Rc<dyn Curve>,
point: Point,
cparam1: ParamId,
cparam2: ParamId,
angle: ParamId,
) -> Self {
Self { crv1, crv2, point, cparam1, cparam2, angle }
}
}
impl Constraint for AngleViaPointAndTwoParams {
fn params(&self) -> Vec<ParamId> {
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<dyn Curve>,
ray2: Rc<dyn Curve>,
boundary: Rc<dyn Curve>,
poa: Point,
n1: ParamId,
n2: ParamId,
flip_n1: bool,
flip_n2: bool,
}
impl Snell {
#[allow(clippy::too_many_arguments)]
pub fn new(
ray1: Rc<dyn Curve>,
ray2: Rc<dyn Curve>,
boundary: Rc<dyn Curve>,
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<ParamId>) -> (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<ParamId> {
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<dyn Curve> = 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<dyn Curve> = 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<dyn Curve>, Rc<dyn Curve>, 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<dyn Curve> = Rc::new(Line { p1: poa, p2: l1_end });
let l2: Rc<dyn Curve> = 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<dyn Curve> = 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<dyn Curve> = 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<dyn Curve> =
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<dyn Curve> = 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<dyn Curve> =
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<dyn Curve> =
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<dyn Curve> = Rc::new(Line { p1: poa, p2: make_point(&mut store, 0.0, 10.0) });
let ray1: Rc<dyn Curve> = Rc::new(Line { p1: make_point(&mut store, -5.0, 0.0), p2: poa });
let ray2: Rc<dyn Curve> = 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<dyn Curve> = 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<dyn Curve> = Rc::new(Line { p1: make_point(&mut store, -5.0, -2.0), p2: poa });
let ray2: Rc<dyn Curve> = 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);
}
}
-108
View File
@@ -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<ParamId>;
/// 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(&param) {
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<ParamId, f64>, 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}"
);
}
}
@@ -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<ParamId> {
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<ParamId> {
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<ParamId>,
pub weights: Vec<f64>,
}
impl CenterOfGravity {
pub fn new(center: ParamId, points: Vec<ParamId>, weights: Vec<f64>) -> Self {
assert_eq!(points.len(), weights.len());
Self { center, points, weights }
}
}
impl Constraint for CenterOfGravity {
fn params(&self) -> Vec<ParamId> {
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<ParamId>,
pub weights: Vec<ParamId>,
pub factors: Vec<f64>,
}
impl WeightedLinearCombination {
pub fn new(point: ParamId, poles: Vec<ParamId>, weights: Vec<ParamId>, factors: Vec<f64>) -> 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<ParamId> {
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<ParamId> {
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(&param) {
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<ParamId, f64>, 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<ParamId> {
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(&param) {
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<ParamId, f64>, 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<ParamId> {
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(&param) {
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<ParamId>) -> (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<ParamId> {
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<ParamId> {
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<ParamId> {
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<ParamId> {
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(&param) {
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<ParamId>) -> (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<ParamId> {
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);
}
}
-265
View File
@@ -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<usize>,
}
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<dyn Constraint>> {
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<usize> = Vec::with_capacity(csize);
let mut redundant: Vec<usize> = 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<Rc<dyn Constraint>> = 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<f64>, rows: &[usize]) -> DMatrix<f64> {
let owned_rows: Vec<_> = rows.iter().map(|&r| m.row(r).clone_owned()).collect();
DMatrix::from_rows(&owned_rows)
}
fn matrix_rank(m: &DMatrix<f64>) -> 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<dyn Constraint> = 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<dyn Constraint> = Rc::new(Equal::new(a, b, 1.0));
let c2: Rc<dyn Constraint> = 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<dyn Constraint> = Rc::new(Difference::new(a, b, d1));
let c2: Rc<dyn Constraint> = 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<dyn Constraint> = Rc::new(Equal::new(a, b, 1.0));
let c2: Rc<dyn Constraint> = 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<dyn Constraint> = Rc::new(Difference::new(a, b, d1));
let c2: Rc<dyn Constraint> = 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"
);
}
}
File diff suppressed because it is too large. Load diff
-140
View File
@@ -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<usize>,
}
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<dyn Constraint>]) -> Vec<Vec<usize>> {
let n = constraints.len();
let mut uf = UnionFind::new(n);
let mut first_seen: HashMap<ParamId, usize> = HashMap::new();
for (i, constr) in constraints.iter().enumerate() {
for param in constr.params() {
match first_seen.get(&param) {
Some(&j) => uf.union(i, j),
None => {
first_seen.insert(param, i);
}
}
}
}
let mut groups: HashMap<usize, Vec<usize>> = 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<usize>>) -> Vec<Vec<usize>> {
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<Rc<dyn Constraint>> = 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<Rc<dyn Constraint>> = 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<Rc<dyn Constraint>> = 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]]);
}
}
-15
View File
@@ -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;
-182
View File
@@ -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<f64>,
pub y: DMatrix<f64>,
pub z: DMatrix<f64>,
}
/// 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<f64>, g: &DVector<f64>, a: &DMatrix<f64>, c: &DVector<f64>) -> Option<QpEqSolution> {
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<f64>, dim: usize) -> DMatrix<f64> {
const TOL: f64 = 1e-10;
let rank = q1.ncols();
if dim == rank {
return DMatrix::zeros(dim, 0);
}
let mut basis: Vec<DVector<f64>> = (0..rank).map(|i| q1.column(i).into_owned()).collect();
let mut extra: Vec<DVector<f64>> = 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}");
}
}
}
-169
View File
@@ -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>) -> 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::<f64>::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<dyn crate::constraints::Constraint> = 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);
}
}
-209
View File
@@ -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<dyn crate::constraints::Constraint> = 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<dyn crate::constraints::Constraint> = 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);
}
}
-176
View File
@@ -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<dyn crate::constraints::Constraint> = 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);
}
}
-38
View File
@@ -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,
}
-268
View File
@@ -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<Rc<dyn Constraint>>,
/// 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<ParamId>,
}
impl SubSystem {
pub fn new(clist: Vec<Rc<dyn Constraint>>, params: &[ParamId]) -> Self {
let candidates: HashSet<ParamId> = params.iter().copied().collect();
let mut referenced: HashSet<ParamId> = 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<ParamId> = 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<dyn Constraint>] {
&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<f64> {
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<f64> {
let mut jacobi = DMatrix::zeros(self.c_size(), params.len());
for (j, &param) 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<f64> {
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<f64> {
let mut grad = DVector::zeros(params.len());
for (j, &param) 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<f64> {
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<ParamId, f64> =
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<f64> {
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<f64>) {
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<ParamId> {
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<dyn Constraint> = 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<dyn Constraint> = 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<dyn Constraint> = 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<dyn Constraint> = 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<dyn Constraint> = 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);
}
}
-155
View File
@@ -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<Rc<dyn Constraint>>,
}
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<dyn Constraint>) {
self.constraints.push(constraint);
}
pub fn constraints(&self) -> &[Rc<dyn Constraint>] {
&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<SubSystem> {
graph::partition_by_shared_params(&self.constraints)
.into_iter()
.map(|indices| {
let clist: Vec<Rc<dyn Constraint>> = indices.iter().map(|&i| self.constraints[i].clone()).collect();
let mut params: Vec<ParamId> =
clist.iter().flat_map(|c| c.params()).filter(|&p| !self.store.is_driven(p)).collect();
params.sort();
params.dedup();
SubSystem::new(clist, &params)
})
.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);
}
}
-161
View File
@@ -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<f64>,
driven: Vec<bool>,
}
/// 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<f64>);
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));
}
}
-148
View File
@@ -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<EmbeddedEntity>` — 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.
File diff suppressed because it is too large. Load diff
-414
View File
@@ -1,414 +0,0 @@
# DWG-Native Constraint Compatibility — Design
## 1. What this is, and why
Hakan (upstream maintainer) reviewed the sketch-constraint system
(`docs/parametric_system_design.md`) and asked for it to persist using the
constraint object model already exposed in `acadrust`/`cadcodec` (the pinned
CAD kernel), so a file saved by OpenCADStudio carries constraints AutoCAD or
BricsCAD would recognize as their own native objects — not just an opaque
custom blob only this app understands.
This is purely a **persistence-format** question, not a solver question.
`ocs_gcs` (the FreeCAD `planegcs` port: `crates/ocs_gcs/src/solvers/{bfgs,dogleg,lm}.rs`)
is the numerical engine that computes geometry from a constraint system —
that stays exactly as it is regardless of what this document decides. What
changes is only how `SketchConstraintSet` gets written to and read from a
`CadDocument`, in `src/scene/sketch_persist.rs`, currently a custom
`bincode`-in-XRecord blob keyed `OCS_SKETCH_CONSTRAINTS`.
## 2. What `acadrust` already provides (confirmed from source)
All paths below are relative to the pinned checkout
(`~/.cargo/git/checkouts/cadcodec-*/*/src/`, rev pinned in `Cargo.toml`).
- `objects/associative.rs` defines AutoCAD's real 2D geometric-constraint
object graph as plain Rust types, reachable generically through
`ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(Assoc2dConstraintGroup), .. })`
— the same `document.objects: HashMap<Handle, ObjectType>` map
`sketch_persist.rs` already inserts `XRecord`/`Dictionary` entries into.
- Both DWG (`io/dwg/dwg_stream_{readers,writers}/object_{reader,writer}/associative.rs`,
~1240/~870 lines) and DXF (`io/dxf/{reader,writer}/section_{reader,writer}/associative.rs`)
have **complete read and write** implementations for every type below —
confirmed by reading the actual field-by-field (de)serialization code, not
inferred from the type definitions alone.
- `classes/mod.rs` registers the DXF class names every one of these needs
when written (`(DXF_NAME, "AcDbClassName", ...)` tuples):
`AcDbAssocDependency`, `AcDbAssocGeomDependency`, `AcDbAssocAction`,
`AcDbAssocNetwork`, `AcDbAssoc2dConstraintGroup`, `AcDbAssocVariable`,
`AcDbAssocPersSubentManager`.
### The graph shape
Not a flat list — a small object graph, matching AutoCAD's real associative
framework:
```
AssocNetwork (per block/space that owns constrained geometry)
└─ owned_actions: Vec<Handle> ─→ AssocAction
├─ action_body: Handle ─→ Assoc2dConstraintGroup
│ ├─ work_plane: [Vector3; 3]
│ └─ nodes: Vec<AssocConstraintNode>
│ (both the *constraint* nodes
│ and the *geometry* nodes live
│ in this one Vec — see §3/§4)
└─ dependencies: Vec<AssocActionDependency>
─→ AssocGeomDependency
├─ dependency: AssocDependency
│ └─ dependent_on: Handle
│ (the real Line/Circle/... entity)
└─ persistent_subent: AssocPersistentSubentId
```
`AssocConstraintNode { node_id: i32, status: u8, connections: Vec<i32>,
class_name: String, registry_flag: bool, data: AssocConstraintNodeData }` —
`node_id`/`connections` link nodes to each other *within* the group (a
constraint node's `owner_id` fields point at a geometry node's `node_id`);
`class_name` picks which `AssocConstraintNodeData` variant applies on
read (`read_constraint_node_data(reader, class_name)`,
`dwg_stream_readers/object_reader/associative.rs:663`).
### 2b. Primary sources beyond `cadcodec`'s own code
`cadcodec` ships no constraint-bearing test fixture and its own source only
shows *what bytes get read*, not *what they mean* semantically — §7 (now
mostly resolved) needed Autodesk's own interface documentation to answer.
Two real, first-party sources turned out to be publicly available with no
sample DWG file needed at all:
- **The ObjectARX 2012 SDK's own C++ headers**, mirrored on GitHub at
[Stalso/CadPlugin](https://github.com/Stalso/CadPlugin/tree/master/ObjectARX%202012/inc)
— Autodesk's own copyright header on each file explicitly grants "permission
to use, copy, modify, and distribute this software... for any purpose and
without fee", and this is the SDK Autodesk itself publishes specifically so
third parties can build interoperable tools — not leaked or decompiled
source. `AcDbAssoc2dConstraintGroup.h`, `AcDbAssocNetwork.h`,
`AcDbAssocManager.h`, `AcDbAssocVariable.h`, `AcConstrainedGeometry.h` were
all read directly; their class-level doc comments describe the exact
same object graph `cadcodec` implements, in Autodesk's own words.
- **Autodesk's live public ObjectARX reference**
(`help.autodesk.com/view/OARXMAC/2025/...`) — confirmed the
`AcDb::ImplicitPointType` enum's exact values and per-member descriptions
(used for §4's `point_type` mapping) and `AcDbAssocNetwork`'s
`getInstanceFromObject`/`getInstanceFromDatabase` dictionary-key default
(used for §7 question 1). No login or SDK download needed — the reference
guide is indexed and publicly browsable.
## 3. Constraint-kind mapping
Our `ConstraintKind` (`src/scene/sketch_constraints.rs`) against
`AssocConstraintNodeData` (`objects/associative.rs:885`). The "refs shape"
column is copied from `sketch_solve::build_constraint`'s actual `match`
(`src/scene/sketch_solve.rs:151-208`) — the ground truth for what each kind
expects, not the doc comment.
| `ConstraintKind` | refs shape (from `build_constraint`) | `AssocConstraintNodeData` | Notes |
|---|---|---|---|
| `Coincident` | `[point, point]` | `Geometrical` + `class_name = "ACPOINTCOINCIDENCECONSTRAINT"` | |
| `Horizontal` | `[whole_line]` | `Geometrical` + `"ACHORIZONTALCONSTRAINT"` | |
| `Vertical` | `[whole_line]` | `Geometrical` + `"ACVERTICALCONSTRAINT"` | |
| `Parallel` | `[whole_line, whole_line]` | `Parallel { datum_line_index: None, .. }` | dedicated variant, not `Geometrical` |
| `Perpendicular` | `[whole_line, whole_line]` | `Geometrical` + `"ACPERPENDICULARCONSTRAINT"` | |
| `Equal` (line case) | `[whole_line, whole_line]` | `Geometrical` + `"ACEQUALLENGTHCONSTRAINT"` | |
| `Equal` (circle/arc case) | `[whole_circle, whole_circle]` | `Geometrical` + `"ACEQUALRADIUSCONSTRAINT"` | **our `Equal` doesn't distinguish these — see §5** |
| `Distance` | `[point, point]` | `Distance { direction_type, distance: Option<Vector3>, value_dependency, dimension_dependency, .. }` | needs an `AssocVariable`/value dependency for the driving value — see §5 |
| `Angle` | `[whole_line, whole_line]` | `Angle { sector_type, value_dependency, dimension_dependency, .. }` | same value-dependency need |
| `Radius` | `[whole_circle]` | `RadiusDiameter { mode, value_dependency, dimension_dependency, .. }` | `mode` presumably distinguishes radius vs. diameter — we're always radius |
| `Tangent` | `[circle, circle]` or `[circle, line]`/`[line, circle]` — Line-Line and anything involving an `Arc` are explicitly unsupported today (`sketch_solve.rs:239-241`, `Arc` isn't a supported `EntityGeom` variant yet) | `Geometrical` + `"ACTANGENTCONSTRAINT"` | our own `Arc`-tangent gap predates this document, unrelated to DWG compatibility |
AutoCAD's `is_plain_geometrical_constraint` list also includes several kinds
we have no `ConstraintKind` for at all: `ACCENTERPOINTCONSTRAINT`,
`ACCOLINEARCONSTRAINT`, `ACCONCENTRICCONSTRAINT`, `ACEQUALCURVATURECONSTRAINT`,
`ACEQUALDISTANCECONSTRAINT`, `ACEQUALHELPPARAMETERCONSTRAINT`,
`ACFIXEDCONSTRAINT`, `ACMIDPOINTCONSTRAINT`, `ACNORMALCONSTRAINT`,
`ACPOINTCURVECONSTRAINT`, `ACSYMMETRICCONSTRAINT` — genuinely nothing to map
to yet, not a mapping gap (§5).
## 4. Geometry-node mapping
Every entity a constraint references also needs its own node in the same
`nodes: Vec<AssocConstraintNode>`, so constraint nodes have something to
point `owner_id` at, *and* a paired `AssocGeomDependency` (in the owning
`AssocAction`'s `dependencies`) so the graph — not just the constraint data
— actually references the real `Line`/`Circle`/... entity by `Handle`.
| Our reference | `AssocConstraintNodeData` geometry variant |
|---|---|
| `SketchRef::whole(line)` | `BoundedLine { point, direction, is_ray: false, start_point, end_point, .. }` — confirmed a *bounded* line is the right choice: `Line`/`BoundedLine`'s split exists because AutoCAD's own constrained-geometry set includes true infinite construction lines (`ACCONSTRAINEDCONSTRUCTIONLINE`/`ACCONSTRAINEDDATUMLINE`, `read_constraint_node_data`, `associative.rs:711-714`) as a *separate* case from an ordinary bounded segment; nothing we draw is an infinite line |
| `SketchRef::point(line, 0\|1)` | `ImplicitPoint { point_type: kStartImplicit(0)\|kEndImplicit(1), point_index: -1, curve_id: <the Line/BoundedLine node's node_id>, .. }` — **confirmed**, `AcDb::ImplicitPointType` enum, `kStartImplicit`/`kEndImplicit`/`kMidImplicit`/`kCenterImplicit`/`kDefineImplicit` (0-4), Autodesk's own live ObjectARX reference (§2b). `point_index` is `-1` for every case except `kDefineImplicit` (a spline control point, which we don't reference); `curve_id` is presumably the owning geometry node's own `node_id` (not yet cross-checked against a real file — the *values* are confirmed, this one wiring detail isn't) |
| `SketchRef::whole(circle)` | `Circle { center, normal, direction, radius, start_parameter: 0.0, end_parameter: TAU, .. }` |
| `SketchRef::whole(arc)` | `Arc { .. same fields .. , start_point, end_point }` |
| `SketchRef::center(circle\|arc)` | `ImplicitPoint { point_type: kCenterImplicit(3), point_index: -1, curve_id: <the Circle/Arc node's node_id>, .. }` — confirmed by the same enum (`kCenterImplicit`: "Center point of a circle, arc, ellipse or bounded ellipse") — **not** the separate `Point`/`ACCONSTRAINEDPOINT` variant, which the ObjectARX docs' own class hierarchy (§2b) show is for a *free-standing* constrained point, not one implicitly defined by another curve |
| `SketchRef::point(_, -2)` (reserved, unused today) | would be `kMidImplicit` if ever used — "Mid point of a bounded line ... or arc" is exactly what marker `-2`'s doc comment already reserved this for |
`SketchRef` and this graph already agree on one thing directly: our own doc
comment (`src/scene/sketch_constraints.rs:20-22`) says the `marker`
convention was deliberately borrowed from
`AssocDimensionReference::main_gs_marker` (`src/scene/dimension_assoc.rs`,
already shipping, already reading/writing real `Assoc*` objects for
associative dimensions). That's a different, lighter mechanism than the
constraint graph (an `AcDbOsnapPointRef` chain attached directly to a
`Dimension` entity, not the `AssocNetwork`/`AssocAction` framework — see
`AssocDimensionAssociation`, `objects/associative.rs:1200`), so it's a
precedent for *how this codebase already talks to `Assoc*` types*, not a
template for the constraint graph's own wiring.
## 5. Known gaps (not addressed by this document)
- **`Equal` is one `ConstraintKind`, AutoCAD has (at least) two**:
`ACEQUALLENGTHCONSTRAINT` (lines) vs. `ACEQUALRADIUSCONSTRAINT`
(circles/arcs), plus `ACEQUALCURVATURECONSTRAINT`/
`ACEQUALDISTANCECONSTRAINT` we don't model at all. `build_constraint`
already branches on `refs`' resolved shape at solve time
(`sketch_solve.rs:181-188`), so the same branch can pick the write-side
`class_name` — no data-model change needed for the length/radius split;
the other two AutoCAD sub-kinds are a real, separate gap.
- **`DrivingValue::Named` — confirmed, has a real native equivalent, and the
full wiring is now traced end to end.**
`AcDbAssocVariable` (`objects/associative.rs:1024`: `name`, `expression`,
`evaluator`, `description`, cached `value`) is fully read *and* written on
both DWG and DXF (confirmed in
`dwg_stream_readers/object_reader/associative.rs:1031-1063` and the DXF
reader/writer equivalents) — it's the same associative-action graph a
constraint group lives in (`AssocVariable` wraps an `AssocAction`, exactly
like `Assoc2dConstraintGroup` does), not a separate table.
The two handles on `Distance`/`Angle`/`RadiusDiameter`
(`read_explicit_constraint`, `associative.rs:652-661` — reads them in this
order: `value_dependency` first, `dimension_dependency` second) resolve
to two different, unrelated things:
- **`value_dependency`** → an `AssocValueDependency` object
(`{ dependency: AssocDependency, name, value: AssocEvalVariant }`,
`objects/associative.rs:554`). Its own `value: AssocEvalVariant`
(`{ code: i16 /* DXF resbuf type */, value: AssocEvalValue::Real(f64) }`,
confirmed `associative.rs:579-596`) is the **cached, already-resolved
number** — always present, read fast without evaluating anything. The
embedded `AssocDependency.dependent_on: Handle`
(`read_dependency`, `associative.rs:83-109`) is what actually links to a
named parameter: `Handle::NULL` when the driving value is a bare
literal, or an `AssocVariable`'s handle when it's driven by one. This
gives a direct, confirmed mapping:
- `DrivingValue::Literal(v)` → `AssocValueDependency` with
`value = Real(v)`, `dependency.dependent_on = Handle::NULL`.
- `DrivingValue::Named(name)` → the same, but `dependency.dependent_on`
points at an `AssocVariable{ name, expression: <our formula>, .. }`
object (create once per distinct name, reused by every constraint
referencing it — `value` still carries the current resolved number,
kept in sync with `ParameterTable`'s last evaluation, same duty our
own `dof`/`conflicts` caches already have).
- **`dimension_dependency`** → a *separate* dependency chain
(`AssocAnnotationDependency`/`AssocDimDependencyBody`,
`associative.rs:744-751` and `:1077-1082`) linking to the actual
`Dimension` entity AutoCAD draws next to a constraint to show its
current value on screen — nothing to do with named parameters at all.
Not required for our purposes unless we also want the constraint to
carry a visible on-canvas dimension the way AutoCAD's does (open
question, not yet decided — §7).
Remaining gap before mapping directly: AutoCAD's `expression` is
evaluated by a named, extensible `evaluator` (not fixed) — real-file
survey needed to know what evaluator name(s) actually appear in practice
(§7 open question 4), ours has one hardcoded evaluator.
- **Missing `ConstraintKind`s**: Concentric, Colinear, Symmetric, Fixed,
Midpoint, Normal, CenterPoint, PointOnCurve — real AutoCAD constraint
types with no `ConstraintKind` variant to map from at all. Out of scope
until/unless the sketch-constraint system itself grows them.
- **`Tangent` involving an `Arc`** has no native mapping to design, since
`build_constraint` doesn't support it at all yet (`sketch_solve.rs:239-241`
— `Arc` isn't a supported `EntityGeom` variant in that function). A
pre-existing gap in the constraint system itself, unrelated to and not
blocking this document.
## 6. Persistence architecture: additive, not a replacement
**Proposal: write both.** Keep `OCS_SKETCH_CONSTRAINTS` as the authoritative
round-trip format OpenCADStudio itself reads back
(`sketch_persist::load_sketch_constraints_from_document`), and *additionally*
materialize the `AssocNetwork`/.../`Assoc2dConstraintGroup` graph alongside
it on save, for other applications to read.
Reasoning:
- Our own model carries information the native graph can't represent
losslessly today — `DrivingValue::Named` chief among them (§5), also
`enabled` (a suppressed-but-kept constraint) which may or may not have a
clean native equivalent (`AssocConstraintNodeData`'s `is_active` field on
several variants looks like the right fit, but unconfirmed, §7).
Round-tripping through the native format only and reconstructing our
model from it risks silent data loss on every save/reopen cycle.
- The native graph's own geometry-node fields (`center`, `radius`, `point`,
...) are *snapshots* of the current solved geometry, not derived at load
time — so they need to be kept in sync with whatever `ocs_gcs` last
solved, same as we already do for our own tessellated wire geometry. Two
representations of "the same" constraint, kept consistent by one save
path, is a smaller invariant to maintain than trying to make the native
graph the *only* source of truth for a system it can't fully express.
- This is reversible: if the native graph turns out to work well enough
(once tested against real AutoCAD/BricsCAD opens, §7) to *become* the
primary format later, dropping the redundant `OCS_SKETCH_CONSTRAINTS`
write is a small follow-up, not a rewrite.
## 7. Open questions
Three of the original five are now resolved from Autodesk's own primary
sources (§2b), not just inferred from `cadcodec`'s struct shapes — kept
here with their answers so the reasoning stays visible, not deleted.
`cadcodec` itself still ships no constraint-bearing DWG/DXF fixture and no
constraint-specific test (checked: no hits for `Assoc2dConstraintGroup`/
`AssocConstraintNode` under any `tests/` directory or fixture path in the
dependency), so even the resolved ones haven't been checked against a real
written-and-reopened file yet — that step still matters (§8 stage 1) for
confirming implementation correctness, just not for the *design* questions
below anymore.
1. **Resolved.** Where the top-level `AssocNetwork` attaches:
`AcDbAssocNetwork::getInstanceFromDatabase`/`getInstanceFromObject`'s own
doc comments (§2b) state the default sub-dictionary key directly —
`"ACAD_ASSOCNETWORK"` — used both for the one top-level network (owned by
the database's Named Object Dictionary) and for each per-block-record
network (owned by that `AcDbBlockTableRecord`'s own extension
dictionary — confirming the parallel to `OCS_SKETCH_CONSTRAINTS`'s own
attachment was right). The two are linked: creating a per-block network
with `addToTopLevelNetwork = true` (the default) also registers it as an
action inside the top-level network, so evaluating the top-level network
cascades through every block's sub-network.
2. **Resolved.** Sub-entity addressing for `ImplicitPoint`: confirmed the
real addressing lives entirely in the constraint-node's own
`point_type`/`point_index`/`curve_id` fields, not in
`AssocPersistentSubentId` (that type turned out to be an abstract C++
base class for a *different*, more general subentity-tracking need
elsewhere in the associative framework — irrelevant to 2D constraint
geometry). `AcDb::ImplicitPointType`'s exact enum (§4) settles the
mapping.
3. **Still open.** Whether a partially-populated graph (e.g. omitting
`AssocPersSubentManager`, or leaving some `AssocAction` bookkeeping
fields at their `Default`) still opens cleanly in real AutoCAD, or
whether it requires every field correctly populated to avoid a repair
prompt or silent constraint loss. Genuinely needs a real write-then-open
test, not documentation — this is a robustness/tolerance question, not a
structural one.
4. **Narrowed to almost nothing.** `AssocVariable`'s `evaluator` field:
`AcDbAssocVariable.h`'s own doc comment (§2b) says plainly — "If empty,
the default evaluator for current acad version will be used." Leaving it
as an empty string is officially-supported behavior per the SDK itself,
not a guess. No further investigation needed unless a real file later
shows this doesn't round-trip as expected.
5. **Still open.** DWG version gating — `AssocConstraintNode`'s own reader
already branches on `version.r2013_plus(dxf_version)` for one field
(`dwg_stream_readers/object_reader/associative.rs:604`); unclear whether
other fields need per-version handling this document hasn't looked for.
Likely answerable the same way as questions 1/2/4 were (Autodesk's own
docs usually note version-specific members) — just not done yet.
6. **Still open, low priority.** Whether to bother wiring
`dimension_dependency` at all (§5) — it drives the small on-canvas
dimension label AutoCAD shows next to a constrained value, cosmetic
rather than structural. Leaving it `Handle::NULL` is presumably safe (the
constraint itself is still fully defined without it) but unconfirmed;
deferring this to a later stage either way (§8).
## 8. Suggested staged approach
1. **Proof of concept, narrowest possible slice**: `Horizontal`/`Vertical`
on a single `Line`, no dimensional constraints (sidesteps the
`AssocVariable`/value-dependency wiring entirely, though that's now fully
designed too — §5). Write the graph — `AssocNetwork` at
`"ACAD_ASSOCNETWORK"` on the scope owner's extension dictionary (§7.1),
one `Assoc2dConstraintGroup` action, one `BoundedLine` geometry node, one
`Geometrical`/`"ACHORIZONTALCONSTRAINT"` (or vertical) constraint node —
and open the file in whatever real-DWG-reading tool is available, to
confirm AutoCAD/BricsCAD actually shows a constraint icon on the line and
doesn't flag the file as corrupt. This is what answers open question 3
(partial-graph tolerance) — the one remaining question documentation
alone can't settle.
2. **Geometric constraints, still no driving value**: add `Coincident`
(needs `ImplicitPoint`/`kStartImplicit`/`kEndImplicit`, §4, now
confirmed), `Parallel`, `Perpendicular`, `Equal` (both sub-kinds) —
verify against the same real-file test from stage 1.
3. **Dimensional constraints**: `Distance`, `Angle`, `Radius`, using the now
fully-traced `value_dependency → AssocValueDependency → AssocVariable`
chain (§5) — including deciding whether `DrivingValue::Named` gets the
real native mapping designed there, or stays a resolved-literal degrade
for a first pass.
4. **Everything else**: `Tangent`'s non-circle-circle cases, the missing
`ConstraintKind`s (§5) if/when the sketch-constraint system grows them,
`dimension_dependency` (§7.6) if a visible on-canvas dimension label
turns out to matter.
Each stage's file should be hand-inspected (or opened in real software)
before moving to the next — the graph's correctness genuinely can't be
verified by this codebase's own round-trip tests alone, since a bug that's
symmetric (write it wrong, read the same wrongness back) would pass here and
still fail to open correctly elsewhere.
## 9. Progress
**Implemented** (`src/scene/dwg_native_constraints.rs`, wired into both save
paths in `src/app/update/file.rs` alongside the existing
`materialize_sketch_constraints_for_save`/`materialize_named_parameters_for_save`
calls). All 4 stages from §8 landed together rather than incrementally, since
`ConstraintKind` only has the 10 variants already covered — there was no
narrower slice left once Horizontal/Vertical's plumbing (network/dictionary
creation, the two-pass builder, class-table registration) was in place.
Covers: every `ConstraintKind` (`Coincident`, `Horizontal`, `Vertical`,
`Parallel`, `Perpendicular`, `Equal`'s line/circle split, `Distance`,
`Angle`, `Radius`, `Tangent`), `AssocGeomDependency` for whole-geometry nodes,
`AssocValueDependency`/`AssocVariable` for dimensional constraints including
`DrivingValue::Named`, and a resave-clears-and-rebuilds cleanup pass
(`remove_owned_recursive`) so materializing twice doesn't accumulate orphans.
Verified via real `save_to_bytes`/`load_bytes` round trips through
`acadrust`'s own reader/writer (5 tests in that module) — this confirms
self-consistency with `acadrust`'s implementation of the documented format,
**not** real-world AutoCAD/BricsCAD compatibility (§7.3/§7.5 below remain
unverified; no such software is available in this environment).
Two implementation-time findings not anticipated by the design above:
- **The top-level NOD-anchored network was dropped from scope.**
`named_parameters_persist.rs`'s own doc comment already documents a real
`acadrust` bug: a `Dictionary`-type object (like the database's Named
Object Dictionary) used as an XRecord/dictionary owner loses its extension
data through a DXF save specifically (`CadDocument::ensure_xrecord` only
patches the owner's `xdictionary_handle` when `get_entity_mut(owner)`
succeeds — a Dictionary isn't an entity, so it silently falls back to a
DWG-only side map). Rather than hit that bug, this module only creates a
network per `SketchScope::owner_handle()` (a real `BlockRecord` entity) —
the same already-proven-safe attachment point `sketch_persist.rs` uses. No
top-level network is created at all.
- **DXF cannot carry the dependency chain — DWG is the only fully-faithful
format.** Confirmed by reading `acadrust`'s DXF writer
(`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`): it
deliberately skips writing `AssocDependency`/`AssocValueDependency`/
`AssocGeomDependency`/`AssocVariable` objects to DXF at all
("Unrestorable associative-framework objects are not written") — DWG has
no equivalent filter. A DXF save keeps the `Assoc2dConstraintGroup`/
`AssocNetwork` shell and every node's own embedded geometry, but loses live
geometry/value linkage and every `AssocVariable`. This module still
materializes the same graph for both formats (the shell that survives DXF
is strictly more than nothing, and dangling handles are an ordinary,
tolerated pattern in both formats) rather than special-casing DXF to write
less. Demonstrated directly by
`a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable`
vs. `a_dxf_save_keeps_only_the_constraint_group_shell` in that module's
test suite.
Also confirmed while implementing (not previously documented): the DWG
writer's `Assoc2dConstraintGroup` arm only emits node data at all when
`nodes.first()` is `Some` — an empty `nodes` `Vec` skips node serialization
entirely — so `nodes[0]` must always be a synthetic root pseudo-node (empty
`class_name`, connected to every real node) even for a single-constraint
graph; and a multi-geometry constraint (e.g. `Parallel`) references its
second geometry via the generic `connections: Vec<i32>` field on
`AssocConstraintNode`, not a second dedicated field on
`AssocConstraintNodeData::Parallel` — confirmed from `AcConstraintGroupNode`'s
own doc comment ("the connection between two nodes is not directed").
Of the original five open questions, three were resolved from Autodesk's own
primary documentation (§7.1, 7.2, 7.4) before implementation started — the
network's dictionary key, `ImplicitPoint`'s addressing enum, and the
`AssocVariable` evaluator default were confirmed, not guessed. Two remain
open (§7.3 partial-graph tolerance, §7.5 version gating) and still genuinely
need a real AutoCAD/BricsCAD open, not just more reading — self-consistency
through `acadrust`'s own round trip can't settle either one. Next action is
opening a saved DWG in real software (or at minimum a third-party DWG
inspector) to make progress on those two, and posting the GitHub Discussion
Hakan asked for once that's done.
-477
View File
@@ -1,477 +0,0 @@
# Named Parameters — Design & Handoff
**Status: not started.** This document is the starting context for a new
coding session (mirrors how `docs/parametric_system_design.md` was used for
the sketch-constraint-solver project — read that document's own "Progress"
section for the sibling feature this one builds alongside). The plan file at
`/Users/felix/.claude/plans/snoopy-finding-matsumoto.md` indexes both.
## 1. What this is, and why
OpenCADStudio now has real sketch-level parametric constraints (Coincident,
Horizontal/Vertical, Parallel/Perpendicular, Equal, Tangent, Distance, Angle,
Radius — see `docs/parametric_system_design.md`, fully built and tested).
What it does **not** have is a way to drive several of those dimensional
values from one shared, named, formula-capable source — FreeCAD calls this
its Spreadsheet workbench; AutoCAD calls it the **Parameters Manager**
(`PARAMETERS` command, since AutoCAD 2010) — a table of named variables
(`hole_dia`, `plate_len`, …) that a dimensional constraint's value can
reference instead of a bare literal, so editing the named value ripples to
every constraint that references it. This is explicitly the AutoCAD-parity
gap identified in the "AutoCAD equivalent" discussion this session (see
§7 for that framing) — we are not inventing a new concept, we're filling in
a specific, well-precedented one.
This is a genuinely separate feature from the constraint solver itself: it
needs a new document-level object, an expression parser, and a change to how
driving values are stored — not new `ocs_gcs` solver math.
## 2. Prior architecture decisions (from design discussion, not yet coded)
- **New document-level table object**, one per document (not per-scope/sketch
— parameters are meant to be shared across a whole drawing), holding
`name -> literal-or-formula`. Likely home: mirror
`src/scene/sketch_persist.rs`'s pattern exactly — an `XRecord`/`Dictionary`
blob on a well-known owner (that file's `XRECORD_KEY` /
`materialize_*_for_save` / `load_*_from_document` shape is the direct
precedent to copy, including the version-prefixed bincode envelope and the
hook points into `on_file_opened` / `prepare_native_save` / the wasm save
path / the automation `"new"`/`"open"` ops).
- **Expression parser/evaluator** — a small formula language (`2 * hole_dia +
1.5`), with dependency-cycle detection (A referencing B referencing A must
be rejected, not silently infinite-loop or panic). No existing dependency
in the workspace does this — check crates.io options (e.g. `evalexpr`,
`meval`) against the license stack (GPLv3 app; anything permissive/LGPL is
fine) before hand-rolling one.
- **Driving-value call sites become "literal or named reference"**. The
concrete, known change: `SketchConstraint::driving_param` is currently
`Option<f64>` (`src/scene/sketch_constraints.rs:97`), consumed directly in
`sketch_solve.rs` (`build_constraint`, lines ~184/190/195, one per
Distance/Angle/Radius) via `sys.add_param(c.driving_param?, ...)`. This
would become something like `DrivingValue::Literal(f64) |
DrivingValue::Named(String)`, resolved against the parameter table at
solve time (same rebuild-from-scratch-per-trigger model the constraint
solver already uses — no incremental re-evaluation needed, consistent with
`refresh_sketch_constraints`'s existing "full rebuild per trigger, not
incremental" design choice).
- **Solid-history properties are a second, separate call-site family** —
`src/scene/model/solid_history.rs`'s `PROP_LENGTH`/`PROP_HEIGHT`/etc. are
plain `f64` fields on structs like `SolidHistoryBox` (e.g. `value.length`,
set via the generic property-editing dispatch at `solid_history.rs:2239`).
Whether named parameters should reach these too (so a box's height can
reference the same `plate_thickness` a sketch dimension uses) is a scope
decision for this session to make explicitly, not assume — see open
questions below. If yes, it's the same "literal or reference" change
repeated across a different, larger set of call sites (many more `PROP_*`
constants than there are `ConstraintKind`s).
## 3. Explicitly out of scope for this feature (confirmed in prior discussion)
- **Live sketch-to-3D linking** (extrude/sweep/loft tracking a sketch's
constraint solve) is a separate, larger project. Confirmed this session:
`SolidHistorySweep`/`Revolve`/etc. hold `sweep_entity: Option<EmbeddedEntity>`
— a **snapshotted copy** of the profile geometry, not a live `Handle` back
into the sketch (`src/scene/model/solid_history.rs`, grep `sweep_entity`).
Named parameters can reference solid-history properties (per §2's open
question) without needing this link — that's a narrower, additive step.
Do not conflate the two.
## 4. Suggested staged approach
Mirroring what worked well for the constraint-solver project (`docs/
parametric_system_design.md`'s own staged plan): small, independently
testable stages, each with its own automated tests, progress notes appended
to this document (not a separate changelog) after each stage, live UI
verification where the stage has new UI surface.
1. Data model: the parameter table type + expression parser, unit-tested in
isolation (parse, evaluate, cycle detection) with no document/UI
integration yet.
2. Persistence spike: XRecord round-trip for the new table, following
`sketch_persist.rs`'s exact precedent — prove save/load byte-fidelity
before wiring anything else to it (this is the same order stage 2 of the
constraint-solver plan used, and it caught real assumptions early there).
3. Wire `SketchConstraint::driving_param` to resolve through the table
(Distance/Angle/Radius constraints only, the three existing call sites).
4. UI: a parameters panel (add/rename/delete a named value, edit its
formula, see its resolved number and any error) — likely the
`src/ui/style/dimstyle.rs`-pattern panel already used elsewhere in this
codebase, and a way to pick "use a named parameter" instead of typing a
literal wherever a Distance/Angle/Radius constraint's value is entered
today (`src/modules/draw/constrain/value.rs`'s `DistanceConstraintCommand`
/ `AngleConstraintCommand`).
5. (If scoped in per §2/§3) extend to `solid_history.rs`'s `PROP_*` values.
## 5. Open questions to resolve before/while coding (not resolved by this doc)
1. **Solid-history scope** — does v1 include primitive solid dimensions
(box length, cylinder radius, …), or sketch dimensional constraints only?
Affects how much of §4's stage 5 is in scope.
2. **Expression language surface** — arithmetic only, or also functions
(`min`, `sqrt`, trig)? AutoCAD's Parameters Manager supports a fairly rich
set; matching all of it is not required for v1 but the parser's grammar
should be chosen so functions can be added later without a rewrite.
3. **Undo granularity for a parameter edit** — a named-parameter edit can
ripple through many constraints across many scopes in one edit, similar
to (bigger than) a single constraint's own resolve. Should probably reuse
the same `record_undo_before`/one-history-entry-per-trigger model
`refresh_sketch_constraints` already established, but confirm rather than
assume.
4. **Where does the parameter table live for multi-document workflows?**
Per-document (confirmed in §2) — but does a block/xref carry its own
independent table, or only the top-level document? `sketch_persist.rs`'s
per-`BlockRecord` XRecord scanning pattern suggests per-block-record is
natural to copy, but sketch scopes and a shared parameter table are not
the same kind of thing (scopes are naturally per-sketch; parameters are
naturally document-wide) — don't copy the pattern mechanically without
checking this distinction holds.
5. **Naming collisions / reserved words** — needs a validation rule (valid
identifier characters, no collision with another parameter, sensible
error on a malformed formula) before the UI stage.
## 6. Progress
**Stage 1 done and tested.** `src/scene/named_parameters.rs`, new module
(registered `pub mod named_parameters;` in `src/scene/mod.rs`, alongside
`sketch_constraints`/`sketch_persist`/`sketch_solve`). Pure data + parser,
no document/UI integration yet, per this stage's own scope.
- **`Expr`**: a small AST (`Number`, `Ref`, `Neg`, `Add`/`Sub`/`Mul`/`Div`/
`Pow`, `Call`) plus a hand-rolled tokenizer/recursive-descent parser
(`parse`) — ordinary precedence (`+`/`-` < `*`/`/` < unary `-` < `^`,
right-associative `^`), parens, and scientific-notation numeric literals
(`1.5e-2`). **Deviation from this doc's own §2 suggestion** to check
`evalexpr`/`meval` first: hand-rolled anyway, because cycle detection
needs the set of names an expression references *before* it's evaluated
(`Expr::refs`), and owning the parse tree gives that for free — treating
evaluation as a black box the way a generic crate is normally used would
just mean re-deriving the same dependency information some other way. No
new external dependency added.
- **§5 open question 2 (expression language surface)**: resolved for v1 as
arithmetic + a small, deliberately conservative builtin function set
(`sqrt`, `abs`, `pow`, `min`, `max` — `call_builtin`), chosen as low-risk,
commonly-needed CAD primitives rather than a considered product decision
on the full surface. The grammar's `Call(String, Vec<Expr>)` node means
adding more later needs no parser rewrite, matching this doc's own
call-out. Builtin names are reserved (`is_reserved_name`) so a parameter
can never collide with one — resolves part of open question 5 (naming
collisions) alongside `is_valid_name`'s identifier rule (leading
letter/underscore, then alphanumeric/underscore).
- **Cycle detection happens at `ParameterTable::set` time, not at
resolve/evaluate time** — the design doc's "must be rejected" read as
real-time rejection (matching a spreadsheet's usual UX), not a
resolve-time error. `set` walks the dependency graph from the candidate
formula's own references, through every already-stored formula, looking
for a path back to the name being defined; a reference to a name that
doesn't exist *yet* just terminates that branch rather than counting as a
cycle (order of definition doesn't matter, same as a spreadsheet formula
referencing an empty cell) — it only becomes
`ParamError::UndefinedReference` if `resolve`/`resolve_all` actually needs
that value later and it's still missing. A `resolve`-time cycle guard
exists too (`resolve_inner`'s `stack` check) but is defense-in-depth only
— unreachable through the public API, since `set` already refuses to
create one; the code comment is explicit that this is a safety net (e.g.
against a foreign/hand-edited file deserialized straight into a table
without going through `set`), not a path the design expects to hit.
- **`resolve_all` isolates a failure to just the parameters it actually
affects**, not the whole table — matching this codebase's established
don't-let-one-bad-item-take-down-everything-else pattern from the
constraint system's own dangling-reference handling.
- `ParameterTable::set` is a single upsert (define-or-redefine), not two
separate "must not exist" / "must already exist" entry points — a later UI
stage that wants add-vs-rename semantics can check `contains` itself
first; not a decision this data-layer stage needed to make.
- 17 unit tests: precedence/associativity, scientific notation, malformed
formulas, builtin arity/unknown-function errors, division by zero, name
validation, literal and chained-reference resolution, undefined-reference
reporting, direct and indirect (closes-later) cycle rejection, redefining
a parameter to *break* a would-be cycle, `resolve_all` isolation,
remove-then-resolve leaving a dependent gracefully undefined (not
panicking), upsert semantics, insertion-order stability across
redefinitions, and a `bincode` round-trip (both `Expr` and `Parameter`
derive `Serialize`/`Deserialize` already, ready for stage 2's XRecord
persistence — not exercised through the real save/load path yet, just
in-memory `bincode::serialize`/`deserialize`, the same distinction stage 4
of the sketch-constraint project drew between its own in-memory-agreement
test and its real-save-path tests).
Full regression check: `cargo test --workspace`, 686 passed / 1 failed (the
same pre-existing, unrelated Turkish-glyph-fallback font test noted
throughout `docs/parametric_system_design.md`) / 13 ignored. `cargo clippy
--lib` shows no new warnings in `named_parameters.rs` (pre-existing warnings
elsewhere untouched).
**Still open after stage 1** (per §5): open question 1 (solid-history
scope), 3 (undo granularity), and 4 (per-document vs per-block-record table
scope) — none needed answering yet since nothing here touches the document
model or undo. Open question 2 and part of 5 got a working v1 answer above,
not a final product ruling.
**Stage 2 done and tested.** `src/scene/named_parameters_persist.rs`, new
module (registered `mod named_parameters_persist;` in `src/scene/mod.rs`),
following `sketch_persist.rs`'s exact precedent: the "lazy" model, a
version-prefixed `bincode` blob in one `Chunk`-typed `XRecordEntry`,
materialized right before save and read back right after load. Also went
ahead with the "wire it into open/save" half of stage 2 (not just the
spike) since it's the same four call sites `sketch_persist` already touches
and there was no reason to leave it half-done:
`OpenCADStudio::prepare_native_save` and the wasm save path
(`src/app/update/file.rs`), `OpenCADStudio::on_file_opened`'s native/web
open path (`src/app/update/file.rs:1393`), and the automation `"open"`/
`"new"` ops (`src/app/automation.rs`) — `named_parameters` lives on `Scene`
(`Scene::named_parameters: named_parameters::ParameterTable`, alongside
`sketch_constraints`, for the same co-location-with-`document` reason
stage 3 of the constraint-solver project gave), with `Scene::
named_parameters`/`named_parameters_mut` accessors.
- **A real round-trip gap found and worked around, not patched**: the
obvious owner handle for a document-wide (not per-scope) XRecord is
`document.header.named_objects_dict_handle` — the vendored `cadcodec`
source's own `extension_dictionary_handle` explicitly resolves an
extension dictionary for a `Dictionary`-type owner, and that crate's own
test suite (`tests/issue51.rs`) uses exactly that handle as an XRecord
owner. In practice this round-tripped through DWG but **silently dropped
the XRecord through DXF** — caught by this stage's own real-bytes save/
load tests, not assumed to work from reading the source alone (the same
discipline `sketch_persist`'s stage 4 applied). Root cause, traced into
the vendored source: `CadDocument::ensure_xrecord` only writes the
*owner's own* `xdictionary_handle` field when `get_entity_mut(owner)`
succeeds (i.e. `owner` is 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;
grepping `io/dxf/writer/` for it turns up nothing — the DXF writer only
ever reads the owner's own `xdictionary_handle` field, never the side map,
so a Dictionary-owned extension dictionary is simply invisible to a DXF
save. This is a gap in the vendored crate for this specific owner-type
combination. Per this project's own stated preference (§1.4 of the sibling
design doc: "requires zero changes to the external `acadrust`/`cadkernel`
crates") the fix taken was to pick a different owner rather than patch
the dependency: **`document.header.model_space_block_handle`** — a real
`BlockRecord` entity, the exact anchor `sketch_persist`'s own
`SketchScope::ModelSpace` case already proved clean through both formats.
Not a perfect semantic fit (a parameter table has nothing to do with model
space specifically) but it's guaranteed to exist in every `CadDocument`
and is a real entity, which is the property that actually matters. Worth
revisiting if a later need for a true document-root-anchored XRecord comes
up — the fix would be in `cadcodec` itself (teach `ensure_xrecord` to also
set a `Dictionary`/`Layout`/`XRecord`/`PlotSettings`/`VisualStyle`/
`Material`/`ProxyObject` owner's own `xdictionary_handle` field, not just
entities'), not something to route around a second time.
- 5 new tests (`named_parameters_persist.rs`): in-memory materialize+reload
agreement, real `save_to_bytes`/`load_bytes` round trips for both DXF and
DWG (the two that caught the gap above), an empty table not persisting,
and format-version rejection — same shape as `sketch_persist`'s own test
suite.
Full regression check: `cargo test --workspace`, 691 passed / 1 failed (same
pre-existing, unrelated font test) / 13 ignored. `cargo clippy --lib` shows
no new warnings in either `named_parameters.rs` or
`named_parameters_persist.rs`.
**Stage 3 done and tested.** `SketchConstraint::driving_param` is now
`Option<named_parameters::DrivingValue>` (`sketch_constraints.rs`) instead
of `Option<f64>` — exactly this doc's own §2 suggested shape:
```rust
pub enum DrivingValue {
Literal(f64),
Named(String),
}
```
with `DrivingValue::resolve(&self, table: &ParameterTable) -> Result<f64,
ParamError>` (`named_parameters.rs`) doing the lookup — a literal resolves
to itself, a named reference resolves through the table.
- **Solve-side wiring**: `sketch_solve.rs`'s `build_constraint`/`solve_scope`
now take a `&ParameterTable` (threaded from `Scene::named_parameters` at
both call sites, `refresh_sketch_constraints` and
`solve_sketch_constraints_preview`) and resolve `driving_param` at the
three existing call sites (Distance/Angle/Radius) via `c.driving_param.
as_ref()?.resolve(params).ok()?` — a resolve failure (an undefined
reference, a division by zero; a cycle should be unreachable since `set`
already refuses to create one) is treated exactly like every other
unbuildable-constraint case `build_constraint`'s own doc comment already
documents: the constraint is skipped for this solve, not an error.
- **Judgment call, not dictated by this doc**: how a resolve failure should
surface wasn't an open question this doc raised explicitly, so the
existing precedent decided it — matching a dangling-reference / unbuildable
constraint rather than inventing a new "parameter error" state. A richer
surfacing (e.g. a distinct glyph color, a status-bar warning naming the
broken reference) is possible future work once stage 4's UI exists to
make it discoverable, not something this data/solver-layer stage needed
to add speculatively.
- **`CmdResult::AddSketchConstraint` intentionally left as `Option<f64>`**
(`src/command.rs`) rather than also switching it to `DrivingValue` — no UI
can author a `Named` reference yet (that's stage 4's picker), so every
existing call site (`DistanceConstraintCommand`/`AngleConstraintCommand`
in `src/modules/draw/constrain/value.rs`, the Horizontal/Vertical/etc.
dispatch in `src/app/commands/draw.rs`) needed no changes at all; the one
conversion point is `command_driver.rs`'s `CmdResult::AddSketchConstraint`
handler, which now wraps the literal into `DrivingValue::Literal` right
before calling `SketchConstraintSet::add`. Keeps this stage's diff to the
data/solver layer plus one narrow seam, not a ripple through every typed-
value UI command.
- **`glyph_label`** (`sketch_constraints.rs`) now matches on `&DrivingValue`
instead of a bare `f64`: a literal renders exactly as before, a named
reference renders its name (e.g. "↔ hole_dia") rather than a resolved
number — showing the resolved value too would need threading
`ParameterTable` into the glyph-render call site
(`src/ui/overlay.rs`), which is UI-layer work for stage 4, not this one;
this arm exists only so the match stays exhaustive ahead of that UI (no
command can produce a `Named` driving_param yet, so it isn't reachable
today).
- **`sketch_persist::FORMAT_VERSION` bumped 1 → 2**: `SketchConstraint`'s
serialized shape changed (a bare `f64` became an enum with its own
discriminant), so a version-1 blob's bytes no longer line up with the
current struct — exactly the "detected and gracefully skipped" case that
version byte exists for (§5.2's own call-out: no migration path exists,
bumping the version is the correct response, not a substitute for one). A
previously-saved file's sketch constraints will not reload after this
change; acceptable for personal-use, pre-release local work, not something
a real migration was owed here.
- 2 new tests (`tests/sketch_constraints_solve.rs`): a `Distance`
constraint driven by a named reference solves correctly and re-solves
when the parameter's own value is redefined; a `Distance` constraint
referencing an undefined name is skipped without panicking, leaving the
triggering edit untouched (the same contract the existing "unrelated edit
doesn't trigger a resolve" test already established for dangling
refs). Every existing `driving_param`-touching test (in
`sketch_constraints.rs`, `sketch_persist.rs`,
`tests/sketch_constraints_xrecord_roundtrip.rs`,
`tests/sketch_constraints_solve.rs`) updated to construct
`DrivingValue::Literal(...)` instead of a bare float; all still pass.
- **Testing-methodology note, not a code defect**: discovered while
re-verifying this stage that plain `cargo test --workspace` stops after
the first *crate's* test failures and silently skips the remaining
integration-test binaries in the workspace — the pre-existing, unrelated
font-fallback failure was silently truncating every regression check this
whole project has run with a bare `cargo test --workspace`. Re-ran with
`--no-fail-fast` for this stage's check and confirmed every binary (not
just the lib crate) passes except that one known-unrelated test. Worth
using `--no-fail-fast` for any future full-workspace check in this repo.
Full regression check: `cargo test --workspace --no-fail-fast`, every test
binary passing except the same pre-existing, unrelated font test noted
throughout this document and its sibling. `cargo clippy --lib --tests`
shows no new warnings on any line this stage touched (the two warnings that
do appear in `sketch_solve.rs`/`scene/mod.rs` predate this stage and are on
code this stage didn't modify).
**Stage 4 done and tested (not live-verified in the running app — see the
note below).**
- **The parameters panel** (`src/ui/window/named_parameters.rs`, new): an
in-canvas modal opened by a new `PARAMETERS` command (AutoCAD's own name
for the equivalent feature — `src/app/commands/view.rs`, dispatched
alongside `ALIASEDIT`; registered in the autocomplete/MCP command list
from the start, unlike the six pre-existing constraint commands
`docs/backlog.md` flags as missing that registration). Built by mirroring
`alias_editor.rs`'s buffered-rows pattern as closely as possible rather
than inventing new UI conventions: rows are `(name, formula)`, edited in a
working buffer (`OpenCADStudio::named_parameter_editor_rows`,
`ModalKind::NamedParameters`) and only committed to `Scene::
named_parameters` on Apply — a better fit here than for aliases, even:
`ParameterTable::set` validates (and can reject) a formula immediately, so
writing through to the live table on every keystroke would fight a user
mid-type.
- **Live preview column, beyond what the alias editor has**: each row also
shows its resolved value or its error, recomputed from a scratch
`ParameterTable` built from the *whole buffer's current text* on every
render (`preview`, private to the UI module, unit-tested directly) — not
cached, matching the "cheap enough to rebuild from scratch" philosophy
`ParameterTable`/`sketch_solve.rs` already use throughout. This means a
cross-row problem (a forward reference, a cycle that would close between
two draft rows) shows up immediately, before Apply, not just after a
failed commit.
- **A real gap found while building the preview, not by design**: two rows
typed with the same name would otherwise silently collapse — `set`
upserts by name, so the buffer's later row would just overwrite the
earlier one with no indication anything was discarded. Added
`duplicate_name_rows` (checked first, before any `set` call, by both the
preview and the real Apply) to flag every row sharing a duplicated name as
its own error instead of silently picking one arbitrarily.
- **The other half of stage 4 — "a way to pick a named parameter instead of
a literal"**: resolved as letting the *same* value prompt accept either,
rather than building a separate picker UI. `DistanceConstraintCommand`/
`AngleConstraintCommand` (`src/modules/draw/constrain/value.rs`) now take
a one-time snapshot of the active tab's parameter names at construction
(`known_param_names` — the same `default_value`-is-a-one-time-snapshot
precedent these commands already relied on, since `on_text_input` has no
document access); `parse_driving_value` tries a numeric literal first,
falls back to an exact match against that snapshot, and returns neither if
the typed token is unrecognized (the prompt's existing "invalid input"
behavior, unchanged). This mirrors how AutoCAD's own dimensional-
constraint prompts already accept an expression in place of a bare value,
and needed no new interaction design. `CmdResult::AddSketchConstraint`'s
`driving_param` (left as a bare `Option<f64>` in stage 3, deliberately,
exactly so this stage could revisit it) is now `Option<DrivingValue>`.
- **Apply re-solves what it affects**: because editing a named parameter is
the entire point of the feature ("editing the named value ripples to
every constraint that references it," §1), Apply doesn't just rebuild the
table — it also gathers every entity handle touched by an enabled
constraint whose `driving_param` is `DrivingValue::Named` (across every
scope; the whole table changed, not one isolated value, so there's no
cheaper "which handles actually changed" test worth doing) and runs them
through the same `begin_undo`/`bump_entities`/`commit_undo_delta` bracket
`resolve_one_sketch_conflict` already established
(`OpenCADStudio::apply_named_parameter_editor_rows`, `command_driver.rs`).
- **Judgment call on undo granularity (§5 open question 3), not fully
resolved**: the geometry Apply moves is one undo step (bracketed as
above), matching how every other solve-triggering action in this codebase
behaves. The parameter *table* edit itself has no undo entry of its own
yet — that would need a new `HistorySnapshot` variant mirroring
`SketchConstraints` (the sibling project's own stage 9), which this pass
didn't build. Documented, not silently dropped, exactly like stage 9's own
"two presses, not one" scope-down: undoing after an Apply undoes the
geometry it moved, not the parameter values.
- 15 new tests: 3 for `parse_driving_value` (literal-first, name match,
unrecognized token), 6 for the panel's `preview` (cross-row reference,
forward reference regardless of buffer order, a cycle flagged on the row
that closes it, a blank row left unflagged, a malformed formula, the
duplicate-name fix), and 6 integration tests in `command_driver.rs`
(applying rows defines the parameter *and* re-solves a constraint that
already referenced it by name — including re-applying after redefining the
parameter; a malformed row is reported as an error and doesn't block a
good row alongside it; the duplicate-name fix exercised end to end through
`apply_named_parameter_editor_rows`, not just the UI-layer preview).
- **Testing-methodology note carried over from stage 3**: verified with
`cargo test --workspace --no-fail-fast` throughout this stage too, for the
same reason noted there.
Full regression check: `cargo test --workspace --no-fail-fast`, 703 tests
passing, only the same pre-existing, unrelated font-fallback failure noted
throughout this document and its sibling. `cargo clippy --lib --tests` shows
no new warnings on any line this stage touched.
**Not live-verified in the running app this pass** — a real gap, not a
formality skipped by habit: this is genuinely new UI surface (a whole modal
panel, plus a new branch in an existing text-input prompt), and static
review/unit tests can't catch every real-app integration issue (layout,
widget-API misuse that only manifests at runtime, focus/keyboard handling).
Per `docs/backlog.md`'s own live-testing notes, exercising it for real needs
a release build swapped into a scratch copy of the installed app bundle
(`open_application`/`request_access` always resolve to the `/Applications`
copy, and the debug binary doesn't reliably present a controllable window in
this environment) — a real time investment this pass didn't spend, flagged
explicitly rather than silently assumed to work. First things to check in a
future live-testing pass: the panel actually opens via `PARAMETERS`, the
value column updates live while typing, the duplicate/cycle/malformed-
formula error text renders legibly, Apply visibly moves geometry for a
constraint driven by a named parameter, and typing a parameter's name at a
`DCONSTRAINT`/`ACONSTRAINT` prompt is actually accepted.
## Follow-up work not yet done
- **Solid-history `PROP_*` values** (§2's own flagged scope decision, open
question 1) — named parameters currently only reach `SketchConstraint::
driving_param` (Distance/Angle/Radius), not `solid_history.rs`'s box
length/cylinder radius/etc. Not started; a decision to make, not assumed.
- **Constraint-table-edit undo** (open question 3, only partially resolved —
see above).
- **Reserved-word/name-collision UX polish**: `ParameterTable::set` already
rejects an invalid or builtin-colliding name (stage 1); the panel surfaces
that rejection as the row's error text via `preview`/Apply, but there's no
live per-keystroke nudge beyond that (e.g. graying out `Apply` while any
row has an error) — a small, optional polish, not a functional gap.
- **Live UI verification**, per the note above.
File diff suppressed because it is too large. Load diff
-55
View File
@@ -1,55 +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 — 3D Modeling and Visualization">
<title>3D Modeling and Visualization — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details class="current-topic" open><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">3D Modeling and Visualization</h1>
<h3 id="solids-and-surfaces">Solids and surfaces</h3>
<p>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.</p>
<h3 id="visual-styles-and-lighting">Visual styles and lighting</h3>
<p>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.</p>
<h3 id="tessellation-quality">Tessellation quality</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="layouts-plotting.html">← Layouts and Plotting</a><a rel="next" href="parametric-constraints.html">Parametric Constraints →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-43
View File
@@ -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.
-55
View File
@@ -1,55 +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 — Annotation and Dimensions">
<title>Annotation and Dimensions — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details class="current-topic" open><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Annotation and Dimensions</h1>
<h3 id="text-and-annotation">Text and annotation</h3>
<p>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.</p>
<h3 id="dimensions">Dimensions</h3>
<p>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.</p>
<h3 id="annotation-scale">Annotation scale</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="blocks-references.html">← Blocks and References</a><a rel="next" href="layouts-plotting.html">Layouts and Plotting →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-190
View File
@@ -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; }
}
-24
View File
@@ -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; }
}
-9
View File
@@ -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.
-120
View File
@@ -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();
}());
-55
View File
@@ -1,55 +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 — Automation and Plugins">
<title>Automation and Plugins — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details class="current-topic" open><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Automation and Plugins</h1>
<h3 id="automation-interface">Automation interface</h3>
<p>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.</p>
<h3 id="plugins">Plugins</h3>
<p>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.</p>
<h3 id="native-and-web-differences">Native and web differences</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="commands-shortcuts.html">← Commands and Shortcuts</a><a rel="next" href="troubleshooting-recovery.html">Troubleshooting and Recovery →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-55
View File
@@ -1,55 +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 — Blocks and References">
<title>Blocks and References — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details class="current-topic" open><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Blocks and References</h1>
<h3 id="create-and-insert-blocks">Create and insert blocks</h3>
<p>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.</p>
<h3 id="array-inserts">Array inserts</h3>
<p>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.</p>
<h3 id="attributes-and-external-references">Attributes and external references</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="layers-properties.html">← Layers and Properties</a><a rel="next" href="annotation-dimensions.html">Annotation and Dimensions →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-55
View File
@@ -1,55 +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 — Commands and Shortcuts">
<title>Commands and Shortcuts — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details class="current-topic" open><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Commands and Shortcuts</h1>
<h3 id="command-line">Command line</h3>
<p>Enter a command name or alias, then respond to its prompts. Press <code>Escape</code> to cancel the active command and <code>Return</code> to accept an available default. Exact prompt options vary by command and current selection.</p>
<h3 id="discover-commands">Discover commands</h3>
<p>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.</p>
<h3 id="shortcut-conventions">Shortcut conventions</h3>
<p>Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="parametric-constraints.html">← Parametric Constraints</a><a rel="next" href="automation-plugins.html">Automation and Plugins →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-45
View File
@@ -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"
}
}
-57
View File
@@ -1,57 +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 — Create and Edit Geometry">
<title>Create and Edit Geometry — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details class="current-topic" open><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Create and Edit Geometry</h1>
<h3 id="basic-drawing-commands">Basic drawing commands</h3>
<p>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.</p>
<h3 id="modify-geometry">Modify geometry</h3>
<p>Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification.</p>
<h3 id="grips-and-direct-manipulation">Grips and direct manipulation</h3>
<p>Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it.</p>
<h3 id="undo-redo-and-oops">Undo, redo, and OOPS</h3>
<p><strong>Undo</strong> reverses recorded document operations and <strong>Redo</strong> reapplies them. <strong>OOPS</strong> 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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="workspace-navigation.html">← Workspace and Navigation</a><a rel="next" href="layers-properties.html">Layers and Properties →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-57
View File
@@ -1,57 +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 — Files and Drawings">
<title>Files and Drawings — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details class="current-topic" open><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Files and Drawings</h1>
<h3 id="create-and-open-drawings">Create and open drawings</h3>
<p>Use <strong>New</strong> for a blank document and <strong>Open</strong> 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.</p>
<h3 id="save-and-save-as">Save and Save As</h3>
<p><strong>Save</strong> updates the current drawing. <strong>Save As</strong> 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.</p>
<h3 id="recovery-and-backups">Recovery and backups</h3>
<p>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.</p>
<h3 id="external-resources">External resources</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="getting-started.html">← Getting Started</a><a rel="next" href="workspace-navigation.html">Workspace and Navigation →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-64
View File
@@ -1,64 +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 — Getting Started">
<title>Getting Started — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details class="current-topic" open><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Getting Started</h1>
<h3 id="what-opencad-studio-does">What OpenCAD Studio does</h3>
<p>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.</p>
<h3 id="a-first-drawing">A first drawing</h3>
<ol type="1">
<li>Create a new drawing or open an existing DWG or DXF file.</li>
<li>Confirm the active workspace, units, layer, and object-snap settings.</li>
<li>Create geometry with a drawing command such as <strong>Line</strong>, <strong>Circle</strong>, or <strong>Polyline</strong>.</li>
<li>Edit the result with grips or a modification command.</li>
<li>Save the drawing under a new name until its compatibility has been verified.</li>
</ol>
<blockquote>
<p><strong>Data safety:</strong> Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file.</p>
</blockquote>
<h3 id="how-to-use-this-manual">How to use this manual</h3>
<p>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 <code>monospaced text</code>.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="index.html">← Help overview</a><a rel="next" href="files-drawings.html">Files and Drawings →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-61
View File
@@ -1,61 +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 — Help overview">
<title>Help overview — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div class="current-topic"><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Help overview</h1>
<section class="hero">
<p class="eyebrow">OpenCAD Studio User Manual</p>
<h2>Find the workflow, command, or concept you need</h2>
<p>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.</p>
</section>
<div class="quick-paths" aria-label="Common help paths">
<a href="getting-started.html#a-first-drawing">Create a first drawing</a>
<a href="files-drawings.html#recovery-and-backups">Recover a drawing</a>
<a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a problem</a>
</div>
<h2 id="manual-chapters">Manual chapters</h2>
<div class="chapter-grid"><a class="chapter-card" href="getting-started.html"><strong>Getting Started</strong><span>Begin a drawing safely and learn how to use this manual.</span></a><a class="chapter-card" href="files-drawings.html"><strong>Files and Drawings</strong><span>Open, save, recover, and move drawings with their dependencies.</span></a><a class="chapter-card" href="workspace-navigation.html"><strong>Workspace and Navigation</strong><span>Work with spaces, views, selection, and object snaps.</span></a><a class="chapter-card" href="create-edit-geometry.html"><strong>Create and Edit Geometry</strong><span>Draw, modify, grip-edit, erase, and restore geometry.</span></a><a class="chapter-card" href="layers-properties.html"><strong>Layers and Properties</strong><span>Organize objects and control their inherited appearance.</span></a><a class="chapter-card" href="blocks-references.html"><strong>Blocks and References</strong><span>Create reusable content, arrays, attributes, and file references.</span></a><a class="chapter-card" href="annotation-dimensions.html"><strong>Annotation and Dimensions</strong><span>Add readable text, dimensions, leaders, and scaled annotation.</span></a><a class="chapter-card" href="layouts-plotting.html"><strong>Layouts and Plotting</strong><span>Prepare paper-space sheets and verify plotted output.</span></a><a class="chapter-card" href="3d-modeling.html"><strong>3D Modeling and Visualization</strong><span>Create and inspect solids, surfaces, materials, and visual styles.</span></a><a class="chapter-card" href="parametric-constraints.html"><strong>Parametric Constraints</strong><span>Capture geometric intent and diagnose solve or history issues.</span></a><a class="chapter-card" href="commands-shortcuts.html"><strong>Commands and Shortcuts</strong><span>Use prompts, autocomplete, aliases, and keyboard conventions.</span></a><a class="chapter-card" href="automation-plugins.html"><strong>Automation and Plugins</strong><span>Control drawings programmatically and extend the native app.</span></a><a class="chapter-card" href="troubleshooting-recovery.html"><strong>Troubleshooting and Recovery</strong><span>Diagnose file, geometry, rendering, and history problems.</span></a></div>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="next" href="getting-started.html">Getting Started →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-55
View File
@@ -1,55 +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 — Layers and Properties">
<title>Layers and Properties — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details class="current-topic" open><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Layers and Properties</h1>
<h3 id="layers">Layers</h3>
<p>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.</p>
<h3 id="object-properties">Object properties</h3>
<p>Properties can be assigned explicitly or inherited <strong>ByLayer</strong> or <strong>ByBlock</strong>. Use the Properties surface or commands such as <code>CHPROP</code> to inspect and change selected objects.</p>
<h3 id="styles">Styles</h3>
<p>Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="create-edit-geometry.html">← Create and Edit Geometry</a><a rel="next" href="blocks-references.html">Blocks and References →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-55
View File
@@ -1,55 +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 — Layouts and Plotting">
<title>Layouts and Plotting — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details class="current-topic" open><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Layouts and Plotting</h1>
<h3 id="layouts-and-viewports">Layouts and viewports</h3>
<p>Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes.</p>
<h3 id="page-setup-and-plot-preview">Page setup and plot preview</h3>
<p>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.</p>
<h3 id="export-checks">Export checks</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="annotation-dimensions.html">← Annotation and Dimensions</a><a rel="next" href="3d-modeling.html">3D Modeling and Visualization →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-305
View File
@@ -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.
-55
View File
@@ -1,55 +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 — Parametric Constraints">
<title>Parametric Constraints — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details class="current-topic" open><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Parametric Constraints</h1>
<h3 id="geometric-constraints">Geometric constraints</h3>
<p>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.</p>
<h3 id="editing-constrained-geometry">Editing constrained geometry</h3>
<p>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.</p>
<h3 id="persistence-and-history-limitations">Persistence and history limitations</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="3d-modeling.html">← 3D Modeling and Visualization</a><a rel="next" href="commands-shortcuts.html">Commands and Shortcuts →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-1
View File
@@ -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, scaLine truncated
-256
View File
@@ -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 = ['<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">']
index_class = ' class="current-topic"' if current is None else ""
parts.append(f'<div{index_class}><a class="chapter-link" href="index.html">Help overview</a></div>')
for page in PAGES:
classes = ' class="current-topic"' if page == current else ""
opened = " open" if page == current else ""
links = "".join(
f'<li><a href="{page.filename}#{anchor}">{html.escape(title)}</a></li>'
for anchor, title in headings(chapters[page.title])
)
parts.append(
f'<details{classes}{opened}><summary><a href="{page.filename}">{html.escape(page.title)}</a></summary>'
f'<ul>{links}</ul></details>'
)
parts.append("</nav>")
return "\n".join(parts)
def overview() -> str:
cards = "".join(
f'<a class="chapter-card" href="{page.filename}"><strong>{html.escape(page.title)}</strong>'
f'<span>{html.escape(page.summary)}</span></a>'
for page in PAGES
)
return f"""
<section class="hero">
<p class="eyebrow">OpenCAD Studio User Manual</p>
<h2>Find the workflow, command, or concept you need</h2>
<p>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.</p>
</section>
<div class="quick-paths" aria-label="Common help paths">
<a href="getting-started.html#a-first-drawing">Create a first drawing</a>
<a href="files-drawings.html#recovery-and-backups">Recover a drawing</a>
<a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a problem</a>
</div>
<h2 id="manual-chapters">Manual chapters</h2>
<div class="chapter-grid">{cards}</div>
""".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'<a rel="prev" href="{previous.filename}">← {html.escape(previous.title)}</a>'
if previous else '<a rel="prev" href="index.html">← Help overview</a>'
)
next_link = (
f'<a rel="next" href="{following.filename}">{html.escape(following.title)} →</a>'
if following else ""
)
elif PAGES:
next_link = f'<a rel="next" href="{PAGES[0].filename}">{html.escape(PAGES[0].title)} →</a>'
return f"""<!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 — {html.escape(title)}">
<title>{html.escape(title)} — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
{sidebar(page, chapters)}
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">{html.escape(title)}</h1>
{body}
<nav class="page-navigation" aria-label="Previous and next chapter">{previous_link}{next_link}</nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
"""
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())
-100
View File
@@ -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 <main>")
if not document.has_title:
errors.append(f"{name}: missing <title>")
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())
-59
View File
@@ -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</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details class="current-topic" open><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Troubleshooting and Recovery</h1>
<h3 id="a-drawing-opens-incorrectly">A drawing opens incorrectly</h3>
<p>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.</p>
<h3 id="geometry-is-missing-or-distorted">Geometry is missing or distorted</h3>
<p>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.</p>
<h3 id="rendering-is-incomplete">Rendering is incomplete</h3>
<p>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.</p>
<h3 id="undo-did-not-restore-the-expected-state">Undo did not restore the expected state</h3>
<p>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.</p>
<h3 id="report-a-reproducible-problem">Report a reproducible problem</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="automation-plugins.html">← Automation and Plugins</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
-57
View File
@@ -1,57 +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 — Workspace and Navigation">
<title>Workspace and Navigation — OpenCAD Studio Help</title>
<link rel="stylesheet" href="assets/css/core.css">
<link rel="stylesheet" href="assets/css/opencad.css">
<script src="search-index.js" defer></script>
<script src="assets/js/help.js" defer></script>
</head>
<body>
<header class="topbar">
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
<a class="brand" href="index.html">OpenCAD Studio Help</a>
<div class="search-shell">
<label class="sr-only" for="help-search">Search help</label>
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
<span class="search-key" aria-hidden="true">⌘K</span>
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
</div>
</header>
<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">
<div><a class="chapter-link" href="index.html">Help overview</a></div>
<details><summary><a href="getting-started.html">Getting Started</a></summary><ul><li><a href="getting-started.html#what-opencad-studio-does">What OpenCAD Studio does</a></li><li><a href="getting-started.html#a-first-drawing">A first drawing</a></li><li><a href="getting-started.html#how-to-use-this-manual">How to use this manual</a></li></ul></details>
<details><summary><a href="files-drawings.html">Files and Drawings</a></summary><ul><li><a href="files-drawings.html#create-and-open-drawings">Create and open drawings</a></li><li><a href="files-drawings.html#save-and-save-as">Save and Save As</a></li><li><a href="files-drawings.html#recovery-and-backups">Recovery and backups</a></li><li><a href="files-drawings.html#external-resources">External resources</a></li></ul></details>
<details class="current-topic" open><summary><a href="workspace-navigation.html">Workspace and Navigation</a></summary><ul><li><a href="workspace-navigation.html#model-space-and-paper-space">Model space and paper space</a></li><li><a href="workspace-navigation.html#pan-zoom-and-orbit">Pan, zoom, and orbit</a></li><li><a href="workspace-navigation.html#multiple-model-viewports">Multiple model viewports</a></li><li><a href="workspace-navigation.html#selection-and-object-snaps">Selection and object snaps</a></li></ul></details>
<details><summary><a href="create-edit-geometry.html">Create and Edit Geometry</a></summary><ul><li><a href="create-edit-geometry.html#basic-drawing-commands">Basic drawing commands</a></li><li><a href="create-edit-geometry.html#modify-geometry">Modify geometry</a></li><li><a href="create-edit-geometry.html#grips-and-direct-manipulation">Grips and direct manipulation</a></li><li><a href="create-edit-geometry.html#undo-redo-and-oops">Undo, redo, and OOPS</a></li></ul></details>
<details><summary><a href="layers-properties.html">Layers and Properties</a></summary><ul><li><a href="layers-properties.html#layers">Layers</a></li><li><a href="layers-properties.html#object-properties">Object properties</a></li><li><a href="layers-properties.html#styles">Styles</a></li></ul></details>
<details><summary><a href="blocks-references.html">Blocks and References</a></summary><ul><li><a href="blocks-references.html#create-and-insert-blocks">Create and insert blocks</a></li><li><a href="blocks-references.html#array-inserts">Array inserts</a></li><li><a href="blocks-references.html#attributes-and-external-references">Attributes and external references</a></li></ul></details>
<details><summary><a href="annotation-dimensions.html">Annotation and Dimensions</a></summary><ul><li><a href="annotation-dimensions.html#text-and-annotation">Text and annotation</a></li><li><a href="annotation-dimensions.html#dimensions">Dimensions</a></li><li><a href="annotation-dimensions.html#annotation-scale">Annotation scale</a></li></ul></details>
<details><summary><a href="layouts-plotting.html">Layouts and Plotting</a></summary><ul><li><a href="layouts-plotting.html#layouts-and-viewports">Layouts and viewports</a></li><li><a href="layouts-plotting.html#page-setup-and-plot-preview">Page setup and plot preview</a></li><li><a href="layouts-plotting.html#export-checks">Export checks</a></li></ul></details>
<details><summary><a href="3d-modeling.html">3D Modeling and Visualization</a></summary><ul><li><a href="3d-modeling.html#solids-and-surfaces">Solids and surfaces</a></li><li><a href="3d-modeling.html#visual-styles-and-lighting">Visual styles and lighting</a></li><li><a href="3d-modeling.html#tessellation-quality">Tessellation quality</a></li></ul></details>
<details><summary><a href="parametric-constraints.html">Parametric Constraints</a></summary><ul><li><a href="parametric-constraints.html#geometric-constraints">Geometric constraints</a></li><li><a href="parametric-constraints.html#editing-constrained-geometry">Editing constrained geometry</a></li><li><a href="parametric-constraints.html#persistence-and-history-limitations">Persistence and history limitations</a></li></ul></details>
<details><summary><a href="commands-shortcuts.html">Commands and Shortcuts</a></summary><ul><li><a href="commands-shortcuts.html#command-line">Command line</a></li><li><a href="commands-shortcuts.html#discover-commands">Discover commands</a></li><li><a href="commands-shortcuts.html#shortcut-conventions">Shortcut conventions</a></li></ul></details>
<details><summary><a href="automation-plugins.html">Automation and Plugins</a></summary><ul><li><a href="automation-plugins.html#automation-interface">Automation interface</a></li><li><a href="automation-plugins.html#plugins">Plugins</a></li><li><a href="automation-plugins.html#native-and-web-differences">Native and web differences</a></li></ul></details>
<details><summary><a href="troubleshooting-recovery.html">Troubleshooting and Recovery</a></summary><ul><li><a href="troubleshooting-recovery.html#a-drawing-opens-incorrectly">A drawing opens incorrectly</a></li><li><a href="troubleshooting-recovery.html#geometry-is-missing-or-distorted">Geometry is missing or distorted</a></li><li><a href="troubleshooting-recovery.html#rendering-is-incomplete">Rendering is incomplete</a></li><li><a href="troubleshooting-recovery.html#undo-did-not-restore-the-expected-state">Undo did not restore the expected state</a></li><li><a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a reproducible problem</a></li></ul></details>
</nav>
<main id="main-content">
<div class="content-wrap">
<p class="page-kicker">OpenCAD Studio User Manual</p>
<h1 class="content-title">Workspace and Navigation</h1>
<h3 id="model-space-and-paper-space">Model space and paper space</h3>
<p>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.</p>
<h3 id="pan-zoom-and-orbit">Pan, zoom, and orbit</h3>
<p>Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. <strong>Zoom Extents</strong> is useful when the drawing appears empty or geometry is far from the expected location.</p>
<h3 id="multiple-model-viewports">Multiple model viewports</h3>
<p>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.</p>
<h3 id="selection-and-object-snaps">Selection and object snaps</h3>
<p>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.</p>
<nav class="page-navigation" aria-label="Previous and next chapter"><a rel="prev" href="files-drawings.html">← Files and Drawings</a><a rel="next" href="create-edit-geometry.html">Create and Edit Geometry →</a></nav>
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
</div>
</main>
</body>
</html>
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>OCS-__BUILD_STAMP__</string> <string>Open CAD Studio</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>OpenCADStudio</string> <string>OpenCADStudio</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>OCS-__BUILD_STAMP__</string> <string>Open CAD Studio</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+2 -9
View File
@@ -22,20 +22,13 @@ cd "$(dirname "$0")/.."
TARGET=aarch64-apple-darwin TARGET=aarch64-apple-darwin
DIST=dist DIST=dist
VERSION="${VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}" 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-<this>" 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 if [ -z "${DEVELOPER_ID:-}" ]; then
DEVELOPER_ID="$(security find-identity -v -p codesigning \ DEVELOPER_ID="$(security find-identity -v -p codesigning \
| sed -n 's/.*"\(Developer ID Application: .*\)"/\1/p' | head -1)" | sed -n 's/.*"\(Developer ID Application: .*\)"/\1/p' | head -1)"
fi fi
[ -n "$DEVELOPER_ID" ] || { echo "No Developer ID identity found; set DEVELOPER_ID (or '-' for ad-hoc)." >&2; exit 1; } [ -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)" echo "==> cargo build (app + thumbnailer staticlib + launcher)"
cargo build --release --target "$TARGET" 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" 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 "$DIST/AppIcon.icns" "$DIST/DWG.icns" "$DIST/DXF.icns" "$APP/Contents/Resources/"
cp -R "$EXT" "$APP/Contents/PlugIns/" 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" echo "==> codesign"
if [ "$DEVELOPER_ID" = "-" ]; then if [ "$DEVELOPER_ID" = "-" ]; then
-2
View File
@@ -1057,7 +1057,6 @@ mod tests {
"CHANGELOG", "CHANGELOG",
"CUI", "CUI",
"ALIASEDIT", "ALIASEDIT",
"OPTIONS",
]; ];
for cmd in standalone { for cmd in standalone {
assert!( assert!(
@@ -1613,4 +1612,3 @@ mod tests {
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
} }
} }
+135 -156
View File
@@ -915,16 +915,8 @@ impl OpenCADStudio {
TableCellEditStart::Started TableCellEditStart::Started
} }
/// Design doc §6.2 (stage 8, Phase B live inference): after a `LINE` /// Adds inferred Coincident constraints after a line is committed.
/// command commits `new_handle`, auto-adds a Coincident constraint for /// Holding Shift suppresses inference. No match means no undo entry.
/// 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.
fn infer_and_add_coincident_constraints(&mut self, i: usize, new_handle: Handle, entity: &acadrust::EntityType) { fn infer_and_add_coincident_constraints(&mut self, i: usize, new_handle: Handle, entity: &acadrust::EntityType) {
if self.shift_down { if self.shift_down {
return; return;
@@ -939,7 +931,12 @@ impl OpenCADStudio {
if pairs.is_empty() { if pairs.is_empty() {
return; 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<Handle> = Vec::new(); let mut touched: Vec<Handle> = Vec::new();
for (new_ref, existing_ref) in &pairs { for (new_ref, existing_ref) in &pairs {
touched.push(new_ref.entity); touched.push(new_ref.entity);
@@ -948,13 +945,10 @@ impl OpenCADStudio {
touched.sort(); touched.sort();
touched.dedup(); touched.dedup();
let label = "Coincident (inferred)"; 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); 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 { for (new_ref, existing_ref) in pairs {
self.tabs[i].scene.sketch_constraint_set_mut(scope).add( self.tabs[i].scene.sketch_constraint_set_mut(scope).add(
crate::scene::sketch_constraints::ConstraintKind::Coincident, crate::scene::sketch_constraints::ConstraintKind::Coincident,
@@ -968,31 +962,9 @@ impl OpenCADStudio {
if let Some(pd) = pending { if let Some(pd) = pending {
self.commit_undo_delta(i, pd); 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 /// Removes and re-solves the first conflicting constraint in the active scope.
/// 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.
pub(super) fn resolve_one_sketch_conflict(&mut self) { pub(super) fn resolve_one_sketch_conflict(&mut self) {
let i = self.active_tab; let i = self.active_tab;
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_sketch_scope();
@@ -1002,8 +974,11 @@ impl OpenCADStudio {
let touched: Vec<Handle> = constraint.refs.iter().map(|r| r.entity).collect(); let touched: Vec<Handle> = constraint.refs.iter().map(|r| r.entity).collect();
let label = "Remove conflicting constraint"; 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); 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); self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
@@ -1011,28 +986,10 @@ impl OpenCADStudio {
if let Some(pd) = pending { if let Some(pd) = pending {
self.commit_undo_delta(i, pd); 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
/// rebuilds the active tab's `Scene::named_parameters` from the /// editor's working buffer and re-solves dependent constraints.
/// editor's working buffer, reports any row that failed to validate via
/// the command line, and re-solves every scope with a constraint driven
/// by a named parameter — the entire point of a *named* parameter is
/// that editing it ripples to whatever references it, the same way
/// editing a constraint's literal value already does.
///
/// **Deliberate scope-down, matching this project's established
/// treatment of undo-granularity questions** (design doc §5 open
/// question 3, and the sibling constraint-system project's stage 9): the
/// geometry this ripple moves is bracketed into one undo step below
/// (same `begin_undo`/`bump_entities`/`commit_undo_delta` pattern
/// `resolve_one_sketch_conflict` above uses), but the parameter *table*
/// edit itself has no undo entry of its own yet — that would need a new
/// `HistorySnapshot` variant mirroring `SketchConstraints`
/// (`push_sketch_constraints_history`). Left for a follow-up, not
/// silently dropped: undoing after an Apply undoes the geometry it
/// moved, not the parameter values themselves.
pub(super) fn apply_named_parameter_editor_rows(&mut self) { pub(super) fn apply_named_parameter_editor_rows(&mut self) {
let i = self.active_tab; let i = self.active_tab;
// A name shared by more than one row can't be resolved by picking // A name shared by more than one row can't be resolved by picking
@@ -1057,7 +1014,6 @@ impl OpenCADStudio {
failed += duplicates.len(); failed += duplicates.len();
} }
let param_count = table.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 // The whole table just changed, not one isolated value, so there is
// no cheaper "which handles actually moved" test worth doing here — // 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)) .flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect(); .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() { if !touched.is_empty() {
let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes); self.tabs[i].scene.bump_entities(&changes);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} }
if let Some(pd) = pending {
self.tabs[i].dirty = true; self.commit_undo_delta(i, pd);
}
if failed == 0 { if failed == 0 {
self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref());
} }
@@ -1111,13 +1068,9 @@ impl OpenCADStudio {
if touched.is_empty() { if touched.is_empty() {
return; return;
} }
let pending = self.begin_undo(i, "Edit named parameter", touched.len(), true);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes); 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) — /// Commits one field of one Parameters-section row (Properties panel) —
@@ -1150,24 +1103,30 @@ impl OpenCADStudio {
return Task::none(); return Task::none();
}; };
let (result, resolve_name) = match field { match field {
ParamField::Formula => { ParamField::Formula if typed.is_empty() || typed == current.source => {
if typed.is_empty() || typed == current.source { self.refresh_properties();
self.refresh_properties(); return Task::none();
return Task::none();
}
(self.tabs[i].scene.named_parameters_mut().set(&current.name, &typed), current.name.clone())
} }
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(&current.name, &typed), current.name.clone()),
ParamField::Name => { 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(&current.name); self.tabs[i].scene.named_parameters_mut().remove(&current.name);
let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, &current.source); let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, &current.source);
if outcome.is_err() { if outcome.is_err() {
@@ -1178,17 +1137,49 @@ impl OpenCADStudio {
}; };
if let Err(e) = result { 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.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref());
self.refresh_properties(); self.refresh_properties();
return Task::none(); return Task::none();
} }
if field == ParamField::Name {
let affected: Vec<usize> = 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 == &current.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 == &current.name {
*name = typed.clone();
}
}
}
}
}
self.resolve_named_parameter_edit(i, &resolve_name); self.resolve_named_parameter_edit(i, &resolve_name);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties(); self.refresh_properties();
Task::none() Task::none()
} }
/// Removes Parameters-section row `index` immediately — no confirmation, /// Removes Parameters-section row `index` immediately. Any
/// matching AutoCAD's own Parameters Manager delete button. Any
/// constraint that referenced it gets the same "undefined reference" /// constraint that referenced it gets the same "undefined reference"
/// resolve-failure treatment a formula's own bad reference already gets /// resolve-failure treatment a formula's own bad reference already gets
/// (`build_constraint`) — re-solving surfaces that rather than needing /// (`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 { let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else {
return Task::none(); 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.tabs[i].scene.named_parameters_mut().remove(&name);
self.resolve_named_parameter_edit(i, &name); self.resolve_named_parameter_edit(i, &name);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties(); self.refresh_properties();
Task::none() Task::none()
} }
@@ -1225,7 +1221,13 @@ impl OpenCADStudio {
} }
n += 1; 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"); 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(); self.refresh_properties();
Task::none() Task::none()
} }
@@ -2540,14 +2542,29 @@ impl OpenCADStudio {
self.refresh_properties(); self.refresh_properties();
} }
CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => { 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(); let scope = self.tabs[i].current_sketch_scope();
// Design doc §5.1/§9: captured before the add so the let constraints_before = self
// constraint-set edit itself (not just the geometry it .tabs[i]
// triggers) is undoable — pushed as its own history entry .scene
// below, adjacent to (not merged into) the entity delta. .sketch_constraint_set(scope)
let constraints_before = self.tabs[i].scene.sketch_constraint_set(scope).cloned(); .cloned()
.unwrap_or_else(|| {
crate::scene::sketch_constraints::SketchConstraintSet::new(scope)
});
let touched: Vec<Handle> = refs.iter().map(|r| r.entity).collect(); let touched: Vec<Handle> = refs.iter().map(|r| r.entity).collect();
let pending = self.begin_undo(i, label, touched.len(), true); 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); self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched
.into_iter() .into_iter()
@@ -2562,7 +2579,6 @@ impl OpenCADStudio {
if let Some(pd) = pending { if let Some(pd) = pending {
self.commit_undo_delta(i, pd); self.commit_undo_delta(i, pd);
} }
self.push_sketch_constraints_history(i, label, scope, constraints_before);
} }
CmdResult::AddCoincidentConstraint { point_a, point_b, label } => { CmdResult::AddCoincidentConstraint { point_a, point_b, label } => {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_sketch_scope();
@@ -5466,8 +5482,7 @@ impl OpenCADStudio {
let groups = self.clipboard_deps.groups.clone(); let groups = self.clipboard_deps.groups.clone();
self.tabs[i].scene.recreate_groups(groups, &handle_map); self.tabs[i].scene.recreate_groups(groups, &handle_map);
} }
// Design doc §12: a constraint entirely between pasted entities // Constraints wholly within the pasted selection follow it.
// follows them, matching in-drawing COPY (`Scene::copy_entities`).
self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map); self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map);
// Incremental: `add_entity` already tessellated every pasted top-level // Incremental: `add_entity` already tessellated every pasted top-level
// solid, and existing document solids are still cached — so only newly // 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 /// Constraint state and solved geometry share one undo/redo transaction.
/// 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.
#[test] #[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 mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#); let _ = app.automation_op(r#"{"op":"new"}"#);
let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0);
@@ -6462,9 +6468,6 @@ mod sketch_constraint_undo_tests {
1 1
); );
// First undo: pops the (later-pushed) SketchConstraints entry —
// removes the constraint record but leaves the already-solved
// geometry alone.
app.undo_steps(1); app.undo_steps(1);
assert_eq!( assert_eq!(
app.tabs[app.active_tab] app.tabs[app.active_tab]
@@ -6473,41 +6476,24 @@ mod sketch_constraint_undo_tests {
.map(|s| s.constraints.len()) .map(|s| s.constraints.len())
.unwrap_or(0), .unwrap_or(0),
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!( assert!(
(line_angle_deg(&app, line) - original_angle).abs() < 1e-3, (line_angle_deg(&app, line) - original_angle).abs() < 1e-3,
"second undo should restore the pre-constraint angle" "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"
); );
app.redo_steps(1); app.redo_steps(1);
assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line");
assert_eq!( assert_eq!(
app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(),
1, 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 /// A line endpoint landing on an existing point infers Coincident unless
/// coincides with an existing entity's point should auto-add a /// Shift is held.
/// Coincident constraint — suppressible by holding Shift.
#[test] #[test]
fn drawing_a_line_onto_an_existing_endpoint_infers_a_coincident_constraint() { fn drawing_a_line_onto_an_existing_endpoint_infers_a_coincident_constraint() {
let mut app = OpenCADStudio::new_for_test(); 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 /// Conflict resolution removes one flagged constraint and is undoable.
/// one flagged constraint per call and re-solves, and the removal itself
/// is undoable/redoable via the same stage-9 mechanism.
#[test] #[test]
fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() { fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() {
let mut app = OpenCADStudio::new_for_test(); 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(); 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"); 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 app.undo_steps(1);
// `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);
}
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); 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"); 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(); 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}"); 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 /// 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)); 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 /// Two endpoint picks resolve to a Coincident constraint.
/// 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.
#[test] #[test]
fn coincident_command_resolves_two_picked_points_into_a_constraint() { fn coincident_command_resolves_two_picked_points_into_a_constraint() {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();
+1 -3
View File
@@ -963,9 +963,7 @@ impl OpenCADStudio {
} }
} }
// ── Constraints (persistent: added to the scope's SketchConstraintSet, // ── Persistent constraints ────────────────────────────────────
// solved via Scene::bump_entities, re-solved on every later edit —
// see docs/parametric_system_design.md §6.1) ──
"HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => { "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order(); let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() { if handles.is_empty() {
+4 -2
View File
@@ -298,10 +298,12 @@ pub fn start_allowed(cmd: &str) -> bool {
| "PERF" | "PERF"
| "CUI" | "CUI"
| "ALIASEDIT" | "ALIASEDIT"
| "OPTIONS"
| "OP"
| "CUILOAD" | "CUILOAD"
| "CUIIMPORT" | "CUIIMPORT"
// The start page already offers Options as a button, so the
// command that opens the same dialog belongs here too.
| "OPTIONS"
| "OP"
) )
} }
+9 -16
View File
@@ -240,30 +240,23 @@ impl OpenCADStudio {
return Some(Task::done(Message::AliasEditorOpen)); return Some(Task::done(Message::AliasEditorOpen));
} }
// ── OPTIONS — app preferences dialog ──────────────────────────── // ── CUI — keyboard shortcut / key-binding editor ───────────────
// Registered for autocomplete (`commands/mod.rs`'s "one-shot "CUI" => {
// commands" list) but had no actual dispatch arm anywhere — the return Some(Task::done(Message::ShortcutsPanelOpen));
// 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 // ── OPTIONS / OP — the preferences dialog ──────────────────────
// to do with that feature itself, just discovered along the // Both names were registered for autocomplete but never dispatched,
// way). "OP" is AutoCAD's own short alias for this command. // so typing either one answered "Unknown command" and the dialog
// could only be reached from the button on the start page.
"OPTIONS" | "OP" => { "OPTIONS" | "OP" => {
return Some(Task::done(Message::OptionsOpen)); 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" => { "PARAMETERS" => {
return Some(Task::done(Message::NamedParametersOpen)); return Some(Task::done(Message::NamedParametersOpen));
} }
// ── CUI — keyboard shortcut / key-binding editor ───────────────
"CUI" => {
return Some(Task::done(Message::ShortcutsPanelOpen));
}
// CUIEXPORT <path> — write the keyboard-shortcut customizations // CUIEXPORT <path> — write the keyboard-shortcut customizations
// (the drawing-independent CUI data) to a plain "KEY COMMAND" file. // (the drawing-independent CUI data) to a plain "KEY COMMAND" file.
"CUIEXPORT" => { "CUIEXPORT" => {
+1 -16
View File
@@ -435,25 +435,10 @@ pub struct StartConfig {
pub section: super::StartSection, pub section: super::StartSection,
} }
#[derive(Clone, PartialEq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)] #[serde(default)]
pub struct RibbonConfig { pub struct RibbonConfig {
pub collapse: CollapseMode, 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 { impl AppConfig {
+13 -43
View File
@@ -661,7 +661,6 @@ pub(super) enum HistorySnapshot {
// which one it actually holds. // which one it actually holds.
Delta(Box<DeltaSnapshot>), Delta(Box<DeltaSnapshot>),
ObjectVisibility(ObjectVisibilitySnapshot), ObjectVisibility(ObjectVisibilitySnapshot),
SketchConstraints(SketchConstraintsSnapshot),
} }
impl HistorySnapshot { impl HistorySnapshot {
@@ -669,7 +668,6 @@ impl HistorySnapshot {
match self { match self {
HistorySnapshot::Delta(d) => &d.label, HistorySnapshot::Delta(d) => &d.label,
HistorySnapshot::ObjectVisibility(v) => &v.label, HistorySnapshot::ObjectVisibility(v) => &v.label,
HistorySnapshot::SketchConstraints(s) => &s.label,
} }
} }
@@ -699,7 +697,12 @@ impl HistorySnapshot {
.iter() .iter()
.map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len())) .map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len()))
.sum::<usize>() .sum::<usize>()
.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()), .saturating_add(d.label.len()),
HistorySnapshot::ObjectVisibility(v) => v HistorySnapshot::ObjectVisibility(v) => v
@@ -723,40 +726,10 @@ impl HistorySnapshot {
.saturating_add(v.selected_after.len()) .saturating_add(v.selected_after.len())
.saturating_mul(16) .saturating_mul(16)
.saturating_add(v.label.len()), .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<crate::scene::sketch_constraints::SketchConstraintSet>,
pub(super) after: Option<crate::scene::sketch_constraints::SketchConstraintSet>,
pub(super) label: String,
}
/// Symmetric undo/redo image for session-only object visibility. It contains /// Symmetric undo/redo image for session-only object visibility. It contains
/// no document entity data and therefore can never make isolation serializable. /// no document entity data and therefore can never make isolation serializable.
#[derive(Clone)] #[derive(Clone)]
@@ -793,6 +766,11 @@ pub(super) struct DeltaSnapshot {
/// `document.objects`, so it needs its own channel here rather than /// `document.objects`, so it needs its own channel here rather than
/// riding along with `entities`/`structure`. Almost always empty. /// riding along with `entities`/`structure`. Almost always empty.
pub(super) sketch_constraints: Vec<SketchConstraintsEntryDelta>, pub(super) sketch_constraints: Vec<SketchConstraintsEntryDelta>,
/// 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, pub(super) label: String,
} }
@@ -853,16 +831,8 @@ pub(super) struct ObjectEntryDelta {
pub(super) after: Option<acadrust::objects::ObjectType>, pub(super) after: Option<acadrust::objects::ObjectType>,
} }
/// One sketch-constraint scope's before/after image within an entity /// One sketch-constraint scope's before/after image within an entity delta.
/// `DeltaSnapshot` — e.g. ERASE removing an entity's constraints along with /// This keeps constraint cleanup atomic with edits such as entity erasure.
/// 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.
#[derive(Clone)] #[derive(Clone)]
pub(super) struct SketchConstraintsEntryDelta { pub(super) struct SketchConstraintsEntryDelta {
pub(super) scope: crate::scene::sketch_constraints::SketchScope, pub(super) scope: crate::scene::sketch_constraints::SketchScope,
+19 -52
View File
@@ -1,12 +1,11 @@
use super::{ use super::{
document::{ document::{
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
PendingHistorySnapshot, SketchConstraintsEntryDelta, SketchConstraintsSnapshot, PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta,
StructureSnapshot, TableEntryDelta,
}, },
OpenCADStudio, OpenCADStudio,
}; };
use crate::scene::{ChangeKind, ObjectIsolationState}; use crate::scene::ObjectIsolationState;
use acadrust::{EntityType, Handle}; use acadrust::{EntityType, Handle};
use rustc_hash::{FxHashMap, FxHashSet as HashSet}; use rustc_hash::{FxHashMap, FxHashSet as HashSet};
use std::sync::Arc; 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<String>,
scope: crate::scene::sketch_constraints::SketchScope,
before: Option<crate::scene::sketch_constraints::SketchConstraintSet>,
) {
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<Handle> = 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( pub(super) fn push_entity_group_history(
&mut self, &mut self,
i: usize, i: usize,
@@ -261,6 +226,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: label.into(), label: label.into(),
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -371,6 +337,7 @@ impl OpenCADStudio {
.then_some((pending.active_layer, active_layer_after)), .then_some((pending.active_layer, active_layer_after)),
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)), structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label, label: pending.label,
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -479,6 +446,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::Layers(entries)), structure: Some(StructureSnapshot::Layers(entries)),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label, label: pending.label,
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -535,6 +503,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::TextStyles(entries)), structure: Some(StructureSnapshot::TextStyles(entries)),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label, label: pending.label,
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -591,6 +560,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::DimStyles(entries)), structure: Some(StructureSnapshot::DimStyles(entries)),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label, label: pending.label,
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -655,6 +625,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::Objects(entries)), structure: Some(StructureSnapshot::Objects(entries)),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: pending.label, label: pending.label,
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -689,6 +660,7 @@ impl OpenCADStudio {
object_handles, object_handles,
}), }),
sketch_constraints: Vec::new(), sketch_constraints: Vec::new(),
named_parameters: None,
label: "STYLE".to_string(), label: "STYLE".to_string(),
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -769,7 +741,8 @@ impl OpenCADStudio {
pending.label 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<Arc<EntityType>>, Option<Arc<EntityType>>)> = entity_before let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = entity_before
.into_iter() .into_iter()
.map(|(h, before)| { .map(|(h, before)| {
@@ -803,6 +776,10 @@ impl OpenCADStudio {
}) })
}) })
.collect(); .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 selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let dirty_after = self.tabs[i].dirty; let dirty_after = self.tabs[i].dirty;
let mut structure = pending.structure_before.map(StructureSnapshot::Full); let mut structure = pending.structure_before.map(StructureSnapshot::Full);
@@ -839,6 +816,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure, structure,
sketch_constraints, sketch_constraints,
named_parameters,
label: pending.label, label: pending.label,
}; };
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); 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 }; let value = if undo { &entry.before } else { &entry.after };
*scene.sketch_constraint_set_mut(entry.scope) = value.clone(); *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 { let (sel, dirty) = if undo {
(&d.selected_before, d.dirty_before) (&d.selected_before, d.dirty_before)
} else { } else {
@@ -1168,13 +1149,6 @@ impl OpenCADStudio {
.redo_stack .redo_stack
.push(HistorySnapshot::ObjectVisibility(v)); .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); self.finish_history_apply(i, had_full, layer_panel_changed, &changes);
@@ -1230,13 +1204,6 @@ impl OpenCADStudio {
.undo_stack .undo_stack
.push(HistorySnapshot::ObjectVisibility(v)); .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); self.finish_history_apply(i, had_full, layer_panel_changed, &changes);
+11 -46
View File
@@ -524,12 +524,6 @@ pub(super) struct OpenCADStudio {
snap_angle_input: String, snap_angle_input: String,
/// Model-space lineweight preview scale, in percent (25..=200). /// Model-space lineweight preview scale, in percent (25..=200).
lineweight_display_scale: i32, 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 state and active axis pair.
isometric_drafting: bool, isometric_drafting: bool,
iso_plane: settings::IsoPlane, 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 /// When true (default), the app registers itself as a .dwg/.dxf/.bak file
/// handler on each launch. Toggle with the FILEASSOC command. /// handler on each launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool, pub file_assoc_enabled: bool,
/// When true, saving a drawing with sketch constraints also materializes /// When true, saving creates native constraint objects alongside the
/// them as AutoCAD's own native `AssocNetwork`/`Assoc2dConstraintGroup` /// application's own persistence record. Existing native objects remain
/// object graph (`scene::dwg_native_constraints`), so AutoCAD/BricsCAD /// synchronized regardless of this setting.
/// recognize them as real constraints rather than an opaque OCS-only
/// XRecord. Off by default (#opt-in — adds objects/file weight every
/// user pays for save-after-save, whether or not they need AutoCAD
/// interop). A drawing that already carries this native graph (from a
/// prior save with this on, or from real AutoCAD) keeps it synced on
/// every save regardless of this setting — see
/// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment;
/// this only governs whether the graph is *created* in a scope that
/// doesn't have one yet.
pub write_dwg_native_constraints: bool, pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its /// 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 /// glyph plus a driven value or named-parameter name. When false, every
/// "⌀ hole_dia"). When false, every pill shows just the bare glyph — the /// pill shows only the glyph.
/// value/name text can cover important canvas detail on a dense sketch,
/// and the glyph alone is already enough to see *that* a constraint is
/// present (`docs/parametric_system_design.md` §6.3/§7).
pub show_constraint_values: bool, pub show_constraint_values: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave. /// 0 disables autosave.
@@ -1066,7 +1048,7 @@ pub(super) struct OpenCADStudio {
/// edited. Seeded from `command_aliases` on open, committed back on close. /// edited. Seeded from `command_aliases` on open, committed back on close.
alias_editor_rows: Vec<(String, String)>, 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`, /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`,
/// this isn't a copy of a separate app-level store — the real state /// this isn't a copy of a separate app-level store — the real state
/// lives per-document at `Scene::named_parameters`; this buffer is /// lives per-document at `Scene::named_parameters`; this buffer is
@@ -1999,12 +1981,6 @@ pub enum Message {
CursorTypeChanged(settings::CursorType), CursorTypeChanged(settings::CursorType),
/// Set the model-space lineweight preview scale from Options. /// Set the model-space lineweight preview scale from Options.
LineweightDisplayScaleChanged(i32), 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. /// Edit the optional crosshair RGB value; blank restores automatic contrast.
CrosshairColorChanged(String), CrosshairColorChanged(String),
/// Set the default type/version used when first saving a new drawing. /// 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 /// Register or unregister as the .dwg/.dxf handler, from Options. Same
/// setting the FILEASSOC command carries. /// setting the FILEASSOC command carries.
FileAssocChanged(bool), FileAssocChanged(bool),
/// Toggle writing AutoCAD-native constraint objects on save, from /// Toggle writing native constraint objects on save.
/// Options. See `write_dwg_native_constraints`'s doc comment.
WriteDwgNativeConstraintsChanged(bool), WriteDwgNativeConstraintsChanged(bool),
/// Toggle showing driven values/named-parameter names on constraint /// Toggle showing driven values/named-parameter names on constraint
/// pills, from Options. See `show_constraint_values`'s doc comment. /// pills, from Options. See `show_constraint_values`'s doc comment.
@@ -2542,12 +2517,8 @@ pub enum Message {
CloseLayoutList, CloseLayoutList,
/// Cycle the coordinate readout mode ($COORDS): static → live → polar. /// Cycle the coordinate readout mode ($COORDS): static → live → polar.
CycleCoordsMode, CycleCoordsMode,
/// Design doc §6.4, stage 11: removes one flagged redundant/conflicting /// Removes one flagged redundant or conflicting constraint from the
/// constraint from the current sketch scope (a bounded v1 of the /// current sketch scope.
/// "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).
/// No-op if the scope currently has no flagged conflict. /// No-op if the scope currently has no flagged conflict.
ResolveOneSketchConflict, ResolveOneSketchConflict,
/// Toggle the status-bar customization menu open/closed. /// 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 /// and re-solve every constraint that reads a named parameter; stays
/// open. /// open.
NamedParametersApply, NamedParametersApply,
// ── Parameters / Constraints sections embedded in the Properties panel // ── Parameters / Constraints sections embedded in Properties ──────────
// (AutoCAD-style: a live section instead of the modal above, per-row
// commit-on-submit instead of a whole-table Apply) ─────────────────────
/// Live text of one column of parameter row `index`, keyed by its /// Live text of one column of parameter row `index`, keyed by its
/// `ParameterTable::iter()` position — see `PropValue::ParamRow`. /// `ParameterTable::iter()` position — see `PropValue::ParamRow`.
PropParamInput { PropParamInput {
@@ -2880,8 +2849,7 @@ pub enum Message {
index: usize, index: usize,
field: crate::ui::window::named_parameters::ParamField, field: crate::ui::window::named_parameters::ParamField,
}, },
/// Remove parameter row `index` immediately (no confirmation, matching /// Remove parameter row `index` immediately.
/// AutoCAD's own Parameters Manager delete button).
PropParamDelete(usize), PropParamDelete(usize),
/// Append a fresh, uniquely-named parameter to `Scene::named_parameters`. /// Append a fresh, uniquely-named parameter to `Scene::named_parameters`.
PropParamAddNew, PropParamAddNew,
@@ -3566,7 +3534,6 @@ impl OpenCADStudio {
isolines_awaiting_regen: false, isolines_awaiting_regen: false,
snap_angle_input: "0".to_string(), snap_angle_input: "0".to_string(),
lineweight_display_scale: 100, lineweight_display_scale: 100,
ui_scale: 100,
isometric_drafting: false, isometric_drafting: false,
iso_plane: settings::IsoPlane::Left, iso_plane: settings::IsoPlane::Left,
snap_angle_deg: 0.0, snap_angle_deg: 0.0,
@@ -4180,7 +4147,6 @@ pub fn run() -> iced::Result {
..iced::Settings::default() ..iced::Settings::default()
}) })
.subscription(OpenCADStudio::subscription) .subscription(OpenCADStudio::subscription)
.scale_factor(|state: &OpenCADStudio, _window: window::Id| state.ui_scale as f32 / 100.0)
.title(|state: &OpenCADStudio, window_id: window::Id| { .title(|state: &OpenCADStudio, window_id: window::Id| {
let _ = window_id; // all dialogs are in-canvas modals now let _ = window_id; // all dialogs are in-canvas modals now
if let Some(tab) = state.tabs.get(state.active_tab) { if let Some(tab) = state.tabs.get(state.active_tab) {
@@ -4225,7 +4191,6 @@ pub fn run_web() -> iced::Result {
OpenCADStudio::view_main, OpenCADStudio::view_main,
) )
.subscription(OpenCADStudio::subscription) .subscription(OpenCADStudio::subscription)
.scale_factor(|state: &OpenCADStudio| state.ui_scale as f32 / 100.0)
.title(|_state: &OpenCADStudio| { .title(|_state: &OpenCADStudio| {
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
}) })
+1 -2
View File
@@ -406,8 +406,7 @@ impl OpenCADStudio {
read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()), read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()),
], ],
}, },
// Document-wide named-parameter table (AutoCAD // Document-wide named-parameter table embedded here
// Parameters Manager equivalent), embedded here
// instead of a separate modal — see `PropValue:: // instead of a separate modal — see `PropValue::
// ParamRow`'s doc comment. Belongs on the no- // ParamRow`'s doc comment. Belongs on the no-
// selection (drawing-level) page, not a per-entity // selection (drawing-level) page, not a per-entity
+2 -16
View File
@@ -221,14 +221,6 @@ pub struct UserSettings {
pub crosshair_color: Option<[u8; 3]>, pub crosshair_color: Option<[u8; 3]>,
/// Model-space lineweight preview scale as a percentage. /// Model-space lineweight preview scale as a percentage.
pub lineweight_display_scale: i32, 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. /// Isometric drafting changes the grid and crosshair to the active axis pair.
pub isometric_drafting: bool, pub isometric_drafting: bool,
pub iso_plane: IsoPlane, pub iso_plane: IsoPlane,
@@ -284,13 +276,8 @@ pub struct UserSettings {
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
/// handler on every launch. Toggle with the FILEASSOC command. /// handler on every launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool, pub file_assoc_enabled: bool,
/// When true, saving a drawing also writes its sketch constraints as /// When true, saving also writes sketch constraints as native drawing
/// AutoCAD's own native object graph (`scene::dwg_native_constraints`), /// objects alongside the application's own persistence record.
/// not just the app's own XRecord format. Off by default — opt-in, since
/// it adds file weight every save whether or not AutoCAD/BricsCAD
/// interop is needed. See `write_dwg_native_constraints` on the app
/// struct for the full contract (a drawing that already has this native
/// graph keeps it synced regardless of this setting).
#[serde(default)] #[serde(default)]
pub write_dwg_native_constraints: bool, pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its /// When true (default), a sketch constraint's viewport pill shows its
@@ -402,7 +389,6 @@ impl Default for UserSettings {
cursor_type: CursorType::Crosshair, cursor_type: CursorType::Crosshair,
crosshair_color: None, crosshair_color: None,
lineweight_display_scale: 100, lineweight_display_scale: 100,
ui_scale: 100,
isometric_drafting: false, isometric_drafting: false,
iso_plane: IsoPlane::Left, iso_plane: IsoPlane::Left,
snap_angle_deg: 0.0, snap_angle_deg: 0.0,
+3 -14
View File
@@ -407,7 +407,6 @@ impl OpenCADStudio {
cursor_type: self.cursor_type, cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color, crosshair_color: self.crosshair_color,
lineweight_display_scale: self.lineweight_display_scale, lineweight_display_scale: self.lineweight_display_scale,
ui_scale: self.ui_scale,
isometric_drafting: self.isometric_drafting, isometric_drafting: self.isometric_drafting,
iso_plane: self.iso_plane, iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg, snap_angle_deg: self.snap_angle_deg,
@@ -477,7 +476,6 @@ impl OpenCADStudio {
.map(crate::app::config::rgb_to_hex) .map(crate::app::config::rgb_to_hex)
.unwrap_or_default(); .unwrap_or_default();
self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200); 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.isometric_drafting = s.isometric_drafting;
self.iso_plane = s.iso_plane; self.iso_plane = s.iso_plane;
self.snap_angle_deg = if s.snap_angle_deg.is_finite() { self.snap_angle_deg = if s.snap_angle_deg.is_finite() {
@@ -836,8 +834,6 @@ impl OpenCADStudio {
annotation_auto_scale: self.annotation_auto_scale, annotation_auto_scale: self.annotation_auto_scale,
ribbon: crate::app::config::RibbonConfig { ribbon: crate::app::config::RibbonConfig {
collapse: self.ribbon.collapse_mode(), 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(), plot: self.plot_dialog.clone(),
shortcuts: crate::app::config::ShortcutConfig { shortcuts: crate::app::config::ShortcutConfig {
@@ -895,9 +891,6 @@ impl OpenCADStudio {
self.dock = dock; self.dock = dock;
self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4);
self.ribbon.set_collapse_mode(cfg.ribbon.collapse); 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.plot_dialog = cfg.plot;
self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect(); self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect();
self.shortcut_bindings self.shortcut_bindings
@@ -1449,8 +1442,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].scene.material_base_dir = self.tabs[i].scene.material_base_dir =
path.parent().map(std::path::Path::to_path_buf); path.parent().map(std::path::Path::to_path_buf);
self.tabs[i].scene.document = doc; self.tabs[i].scene.document = doc;
// Design doc §8 stage 4: read back any persisted sketch // Load persisted constraints after installing the document.
// constraint sets right after the document is installed.
self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.load_sketch_constraints_from_document();
// named_parameters_design.md stage 2: same load-time hook, // named_parameters_design.md stage 2: same load-time hook,
// for the document-wide parameter table. // for the document-wide parameter table.
@@ -1718,15 +1710,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64; self.tabs[i].scene.annotation_scale as f64;
self.sync_solid_models_for_save(i); self.sync_solid_models_for_save(i);
// Design doc §8 stage 4: write any sketch constraint sets into the // Materialize constraints immediately before serialization.
// document right before it's serialized.
self.tabs[i].scene.materialize_sketch_constraints_for_save(); self.tabs[i].scene.materialize_sketch_constraints_for_save();
// named_parameters_design.md stage 2: same save-time hook, for the // named_parameters_design.md stage 2: same save-time hook, for the
// document-wide parameter table. // document-wide parameter table.
self.tabs[i].scene.materialize_named_parameters_for_save(); self.tabs[i].scene.materialize_named_parameters_for_save();
// docs/dwg_constraint_compatibility_design.md: additive DWG/DXF- // Keep the optional native constraint graph synchronized on save.
// native constraint graph, alongside (not instead of) the XRecord
// above — same save-time hook.
self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints);
} }
-18
View File
@@ -6040,24 +6040,6 @@ impl OpenCADStudio {
Task::none() 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) => { Message::CrosshairColorChanged(value) => {
self.crosshair_color_input = value.clone(); self.crosshair_color_input = value.clone();
if value.trim().is_empty() { if value.trim().is_empty() {
+2 -10
View File
@@ -1706,16 +1706,8 @@ impl OpenCADStudio {
); );
} }
} }
// Live sketch-constraint re-solve (design doc §7 open question // Re-solve constrained neighbors on each drag frame and include
// 6): previously constrained neighbors only snapped into place // their original state in the gesture's undo record.
// 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.
let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles); let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles);
for (handle, _) in &solved_by_constraints { for (handle, _) in &solved_by_constraints {
let handle = *handle; let handle = *handle;
+1 -3
View File
@@ -805,9 +805,7 @@ bg={bg_ms:.1}ms n={view_count}"
let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| { let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| {
!cmd.needs_entity_pick() && !cmd.is_selection_gathering() !cmd.needs_entity_pick() && !cmd.is_selection_gathering()
}); });
// Persistent sketch-constraint glyphs (design doc §6.3/§7) — model // Constraint glyphs are displayed in model space only.
// space only, like the constraints themselves; paper space shows
// none (out of scope per the design doc §3.1).
let constraint_glyphs: Vec<(iced::Point, String, bool)> = if is_paper { let constraint_glyphs: Vec<(iced::Point, String, bool)> = if is_paper {
Vec::new() Vec::new()
} else { } else {
-3
View File
@@ -265,9 +265,6 @@ impl OpenCADStudio {
self.crosshair_color, self.crosshair_color,
&self.crosshair_color_input, &self.crosshair_color_input,
self.lineweight_display_scale, self.lineweight_display_scale,
self.ui_scale,
self.ribbon.label_font_size(),
self.ribbon.group_title_font_size(),
&self.model_space, &self.model_space,
&self.model_bg_input, &self.model_bg_input,
&self.paper_bg_input, &self.paper_bg_input,
+8 -20
View File
@@ -1408,8 +1408,8 @@ pub enum CmdResult {
ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>), ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>),
/// Replace several entities as one undo step while keeping the command active. /// Replace several entities as one undo step while keeping the command active.
ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>), ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>),
/// Add a persistent parametric constraint (`docs/parametric_system_design.md` /// Add a persistent parametric constraint to the current scope's
/// §6.1) to the current scope's `SketchConstraintSet` and trigger its /// `SketchConstraintSet` and trigger its
/// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`, /// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`,
/// nothing here is geometry to add or replace directly — the host adds /// nothing here is geometry to add or replace directly — the host adds
/// the constraint record, then re-solves through `Scene::bump_entities`'s /// the constraint record, then re-solves through `Scene::bump_entities`'s
@@ -1419,8 +1419,7 @@ pub enum CmdResult {
kind: crate::scene::sketch_constraints::ConstraintKind, kind: crate::scene::sketch_constraints::ConstraintKind,
refs: Vec<crate::scene::sketch_constraints::SketchRef>, refs: Vec<crate::scene::sketch_constraints::SketchRef>,
/// The typed target for a dimensional kind (Distance/Angle/Radius) — /// The typed target for a dimensional kind (Distance/Angle/Radius) —
/// a literal number, or a named-parameter reference /// a literal number, or a named-parameter reference recognized by
/// (`docs/named_parameters_design.md` stage 4, recognized by
/// `DistanceConstraintCommand`/`AngleConstraintCommand:: /// `DistanceConstraintCommand`/`AngleConstraintCommand::
/// on_text_input` when the typed token matches a known parameter /// on_text_input` when the typed token matches a known parameter
/// name instead of parsing as a number); `None` for a purely /// 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"`. /// Undo-history label, e.g. `"Horizontal constraint"`.
label: &'static str, label: &'static str,
}, },
/// Add a persistent Coincident constraint between whatever sub-entity /// Adds a Coincident constraint between the sub-entity points nearest
/// point resolves nearest each picked point (design doc §6.1/§6.2's /// the two picked positions. The host resolves the raw points because
/// manual Coincident UI — `CoincidentConstraintCommand`, /// `CadCommand` has no document access.
/// `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.
AddCoincidentConstraint { AddCoincidentConstraint {
point_a: DVec3, point_a: DVec3,
point_b: DVec3, point_b: DVec3,
@@ -2534,14 +2528,8 @@ pub fn all_registered_command_names() -> Vec<&'static str> {
mod constraint_registry_tests { mod constraint_registry_tests {
use super::*; use super::*;
/// Regression guard: the whole `src/modules/draw/constrain/` family /// Ensures every constraint command remains discoverable by autocomplete
/// (17 command ids, every one of the geometric/dimensional constraint /// and command listings.
/// commands) predated `inventory::submit!` and was invisible to
/// command-line autocomplete and the MCP `commands` listing —
/// `docs/command_reference.md`'s own generator flagged this. Each
/// constraint module now registers itself; this pins that so the gap
/// can't silently reappear (e.g. a new constraint command added
/// without its own registration).
#[test] #[test]
fn every_constraint_command_is_in_the_autocomplete_registry() { fn every_constraint_command_is_in_the_autocomplete_registry() {
let names = all_registered_command_names(); let names = all_registered_command_names();
+1 -11
View File
@@ -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. /// World-space wire points sampled by the kernel's angular policy.
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> { 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 /// The snap candidates an entity's curve offers, in the two channels the
+12 -4
View File
@@ -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, //! unlike the rest of this module's plain select-and-click constraints,
//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc //! 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 //! center), not the whole entity, so it needs two point picks instead of a
@@ -23,7 +23,9 @@ pub mod coincident_tool {
ToolDef { ToolDef {
id: "CCONSTRAINT", id: "CCONSTRAINT",
label: "Coincident", 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()), event: ModuleEvent::Command("CCONSTRAINT".to_string()),
} }
} }
@@ -59,7 +61,11 @@ impl CadCommand for CoincidentConstraintCommand {
self.first = Some(pt); self.first = Some(pt);
CmdResult::NeedPoint 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 ───────────────────────────────── // ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["CCONSTRAINT"] }); inventory::submit!(crate::command::CommandRegistration {
names: &["CCONSTRAINT"]
});
+15 -7
View File
@@ -1,6 +1,5 @@
//! EDCONSTRAINT — AutoCAD's fourth `Equal` sub-kind //! EDCONSTRAINT makes the distance between one point pair equal the
//! (`ACEQUALDISTANCECONSTRAINT`): the distance between one point pair //! distance between another pair, rather than comparing whole entities.
//! equals the distance between another, rather than two whole entities
//! being equal length/radius. Needs four point picks (unlike the two //! being equal length/radius. Needs four point picks (unlike the two
//! whole-entity selections `ECONSTRAINT` uses), so — like //! whole-entity selections `ECONSTRAINT` uses), so — like
//! `CoincidentConstraintCommand` — it accumulates raw points and hands them //! `CoincidentConstraintCommand` — it accumulates raw points and hands them
@@ -17,7 +16,9 @@ pub mod equal_distance_tool {
ToolDef { ToolDef {
id: "EDCONSTRAINT", id: "EDCONSTRAINT",
label: "Equal Distance", 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()), event: ModuleEvent::Command("EDCONSTRAINT".to_string()),
} }
} }
@@ -53,8 +54,13 @@ impl CadCommand for EqualDistanceConstraintCommand {
if self.points.len() < 4 { if self.points.len() < 4 {
return CmdResult::NeedPoint; return CmdResult::NeedPoint;
} }
let [a, b, c, d] = self.points[..] else { unreachable!("just checked len == 4") }; let [a, b, c, d] = self.points[..] else {
CmdResult::AddEqualDistanceConstraint { points: [a, b, c, d], label: "Equal distance constraint" } unreachable!("just checked len == 4")
};
CmdResult::AddEqualDistanceConstraint {
points: [a, b, c, d],
label: "Equal distance constraint",
}
} }
fn on_enter(&mut self) -> CmdResult { fn on_enter(&mut self) -> CmdResult {
@@ -67,4 +73,6 @@ impl CadCommand for EqualDistanceConstraintCommand {
} }
// ── Autocomplete registry ───────────────────────────────── // ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["EDCONSTRAINT"] }); inventory::submit!(crate::command::CommandRegistration {
names: &["EDCONSTRAINT"]
});
+6 -4
View File
@@ -1,5 +1,4 @@
//! Ribbon tools and typed-value commands for the persistent "Constraints" //! Ribbon tools and typed-value commands for persistent constraints.
//! group (design doc `docs/parametric_system_design.md` §6.1).
//! //!
//! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`] //! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`]
//! to the current scope and lets `Scene::bump_entities` solve it, the same //! to the current scope and lets `Scene::bump_entities` solve it, the same
@@ -29,8 +28,11 @@ mod tools;
mod value; mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use coincident::{coincident_tool, CoincidentConstraintCommand};
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; 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::{ 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}; pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand};
+30 -7
View File
@@ -24,7 +24,9 @@ pub mod center_point_tool {
ToolDef { ToolDef {
id: "CPCONSTRAINT", id: "CPCONSTRAINT",
label: "Center Point", 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()), event: ModuleEvent::Command("CPCONSTRAINT".to_string()),
} }
} }
@@ -36,7 +38,9 @@ pub mod midpoint_tool {
ToolDef { ToolDef {
id: "MPCONSTRAINT", id: "MPCONSTRAINT",
label: "Midpoint", 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()), event: ModuleEvent::Command("MPCONSTRAINT".to_string()),
} }
} }
@@ -48,7 +52,9 @@ pub mod point_on_curve_tool {
ToolDef { ToolDef {
id: "OCCONSTRAINT", id: "OCCONSTRAINT",
label: "Point on Curve", 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()), event: ModuleEvent::Command("OCCONSTRAINT".to_string()),
} }
} }
@@ -63,8 +69,18 @@ pub struct PointOnEntityConstraintCommand {
} }
impl PointOnEntityConstraintCommand { impl PointOnEntityConstraintCommand {
pub fn new(name: &'static str, kind: ConstraintKind, target: Handle, label: &'static str) -> Self { pub fn new(
Self { name, kind, target, label } 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 { 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 { fn on_enter(&mut self) -> CmdResult {
@@ -91,4 +112,6 @@ impl CadCommand for PointOnEntityConstraintCommand {
} }
// ── Autocomplete registry ───────────────────────────────── // ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"] }); inventory::submit!(crate::command::CommandRegistration {
names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"]
});
+34 -13
View File
@@ -8,7 +8,9 @@ pub mod horizontal {
ToolDef { ToolDef {
id: "HCONSTRAINT", id: "HCONSTRAINT",
label: "Horizontal", 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()), event: ModuleEvent::Command("HCONSTRAINT".to_string()),
} }
} }
@@ -20,7 +22,9 @@ pub mod vertical {
ToolDef { ToolDef {
id: "VCONSTRAINT", id: "VCONSTRAINT",
label: "Vertical", 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()), event: ModuleEvent::Command("VCONSTRAINT".to_string()),
} }
} }
@@ -32,7 +36,9 @@ pub mod parallel {
ToolDef { ToolDef {
id: "PCONSTRAINT", id: "PCONSTRAINT",
label: "Parallel", 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()), event: ModuleEvent::Command("PCONSTRAINT".to_string()),
} }
} }
@@ -44,7 +50,9 @@ pub mod perpendicular {
ToolDef { ToolDef {
id: "QCONSTRAINT", id: "QCONSTRAINT",
label: "Perpendicular", 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()), event: ModuleEvent::Command("QCONSTRAINT".to_string()),
} }
} }
@@ -56,7 +64,9 @@ pub mod equal {
ToolDef { ToolDef {
id: "ECONSTRAINT", id: "ECONSTRAINT",
label: "Equal", 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()), event: ModuleEvent::Command("ECONSTRAINT".to_string()),
} }
} }
@@ -68,7 +78,9 @@ pub mod tangent {
ToolDef { ToolDef {
id: "TCONSTRAINT", id: "TCONSTRAINT",
label: "Tangent", 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()), event: ModuleEvent::Command("TCONSTRAINT".to_string()),
} }
} }
@@ -80,7 +92,9 @@ pub mod concentric {
ToolDef { ToolDef {
id: "NCONSTRAINT", id: "NCONSTRAINT",
label: "Concentric", 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()), 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 /// always normal to its own tangent), so it's built from the same
/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line /// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line
/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only /// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only
/// covers line-to-line — AutoCAD's own `GeomConstraintType` enum lists /// covers line-to-line.
/// `kNormal` and `kPerpendicular` separately for exactly this reason.
pub mod normal { pub mod normal {
use super::*; use super::*;
pub fn tool() -> ToolDef { pub fn tool() -> ToolDef {
ToolDef { ToolDef {
id: "NRCONSTRAINT", id: "NRCONSTRAINT",
label: "Normal", 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()), event: ModuleEvent::Command("NRCONSTRAINT".to_string()),
} }
} }
@@ -112,7 +127,9 @@ pub mod colinear {
ToolDef { ToolDef {
id: "LCONSTRAINT", id: "LCONSTRAINT",
label: "Colinear", 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()), event: ModuleEvent::Command("LCONSTRAINT".to_string()),
} }
} }
@@ -124,7 +141,9 @@ pub mod fixed {
ToolDef { ToolDef {
id: "FXCONSTRAINT", id: "FXCONSTRAINT",
label: "Fixed", 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()), event: ModuleEvent::Command("FXCONSTRAINT".to_string()),
} }
} }
@@ -136,7 +155,9 @@ pub mod symmetric {
ToolDef { ToolDef {
id: "SYCONSTRAINT", id: "SYCONSTRAINT",
label: "Symmetric", 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()), event: ModuleEvent::Command("SYCONSTRAINT".to_string()),
} }
} }
+257 -80
View File
@@ -2,8 +2,7 @@
//! (a distance or an angle), unlike the plain select-and-click constraints //! (a distance or an angle), unlike the plain select-and-click constraints
//! in `mod.rs`. //! in `mod.rs`.
//! //!
//! Persistent (design doc `docs/parametric_system_design.md` §6.1): these //! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! gets `&mut self` — no document access — so it can't add a //! gets `&mut self` — no document access — so it can't add a
//! `SketchConstraint` to the scene directly; instead these commands hand the //! `SketchConstraint` to the scene directly; instead these commands hand the
//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which //! 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; use crate::scene::Scene;
/// Recognizes a typed prompt token as either a numeric literal or a /// Recognizes a typed prompt token as either a numeric literal or a
/// reference to an existing named parameter (`docs/ /// reference to an existing named parameter. `known_names` is a one-time snapshot taken at
/// named_parameters_design.md` stage 4's "a way to pick a named parameter
/// instead of typing a literal" — the same prompt takes either, mirroring
/// how AutoCAD's own dimensional-constraint prompts accept an expression in
/// place of a bare value). `known_names` is a one-time snapshot taken at
/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand /// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand
/// ::new`, which already has `&Scene`) since `on_text_input` itself has no /// ::new`, which already has `&Scene`) since `on_text_input` itself has no
/// document access — the same constraint `default_value` already works /// document access — the same constraint `default_value` already works
@@ -57,7 +52,9 @@ pub mod distance_tool {
ToolDef { ToolDef {
id: "DCONSTRAINT", id: "DCONSTRAINT",
label: "Distance", 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()), event: ModuleEvent::Command("DCONSTRAINT".to_string()),
} }
} }
@@ -69,17 +66,15 @@ pub mod angle_tool {
ToolDef { ToolDef {
id: "ACONSTRAINT", id: "ACONSTRAINT",
label: "Angle", 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()), event: ModuleEvent::Command("ACONSTRAINT".to_string()),
} }
} }
} }
/// Which flavor of dimensional constraint the typed value ultimately /// Which flavor of dimensional constraint the typed value drives.
/// drives — selectable via a prompt keyword before the value itself is
/// typed, mirroring how real AutoCAD's own `DCCONSTRAINT` offers
/// `[Xdistance/Ydistance]` or `[Diameter]` as options on the same command
/// rather than as separate tools.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum DistanceMode { enum DistanceMode {
/// Line length (two-point distance) or a circle/arc's radius — /// Line length (two-point distance) or a circle/arc's radius —
@@ -107,6 +102,8 @@ pub struct DistanceConstraintCommand {
is_arc: bool, is_arc: bool,
mode: DistanceMode, mode: DistanceMode,
default_value: f64, default_value: f64,
default_x: f64,
default_y: f64,
/// The entity's current arc length, for `ArcLength` mode's `<default>` /// The entity's current arc length, for `ArcLength` mode's `<default>`
/// — meaningless (left at 0) unless `is_arc`. /// — meaningless (left at 0) unless `is_arc`.
default_arc_length: f64, default_arc_length: f64,
@@ -119,49 +116,89 @@ impl DistanceConstraintCommand {
/// `None` if `handle` isn't a Line, Circle, or Arc. /// `None` if `handle` isn't a Line, Circle, or Arc.
pub fn new(scene: &Scene, handle: Handle) -> Option<Self> { pub fn new(scene: &Scene, handle: Handle) -> Option<Self> {
let entity = scene.document.get_entity(handle)?; let entity = scene.document.get_entity(handle)?;
let (is_circle, is_arc, default_value, default_arc_length) = match entity { let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) =
EntityType::Line(l) => { match entity {
let dx = l.end.x - l.start.x; EntityType::Line(l) => {
let dy = l.end.y - l.start.y; let dx = l.end.x - l.start.x;
(false, false, (dx * dx + dy * dy).sqrt(), 0.0) 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), }
EntityType::Arc(a) => (true, true, a.radius, a.arc_length()), EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0),
_ => return None, 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_arc_length, known_param_names }) 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<CmdResult> { fn build(&self, target: DrivingValue) -> Option<CmdResult> {
if let DrivingValue::Literal(v) = target { if let DrivingValue::Literal(v) = &target {
if v <= 0.0 { let signed_component = matches!(self.mode, DistanceMode::X | DistanceMode::Y);
if !v.is_finite() || (!signed_component && *v <= 0.0) {
return None; return None;
} }
} }
let (kind, refs, label) = match (self.is_circle, self.mode) { let (kind, refs, label) = match (self.is_circle, self.mode) {
(true, DistanceMode::Diameter) => (ConstraintKind::Diameter, vec![SketchRef::whole(self.handle)], "Diameter constraint"), (true, DistanceMode::Diameter) => (
(true, DistanceMode::ArcLength) if self.is_arc => { ConstraintKind::Diameter,
(ConstraintKind::ArcLength, vec![SketchRef::whole(self.handle)], "Arc length constraint") vec![SketchRef::whole(self.handle)],
} "Diameter constraint",
(true, _) => (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius 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) => ( (false, DistanceMode::X) => (
ConstraintKind::DistanceX, 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", "DistanceX constraint",
), ),
(false, DistanceMode::Y) => ( (false, DistanceMode::Y) => (
ConstraintKind::DistanceY, 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", "DistanceY constraint",
), ),
(false, _) => ( (false, _) => (
ConstraintKind::Distance, 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", "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 { fn prompt(&self) -> String {
match (self.is_circle, self.mode) { 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 => { (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, _) if self.is_arc => format!(
(true, _) => format!("Specify distance <{:.4}> or [Diameter]: ", self.default_value), "Specify distance <{:.4}> or [Diameter/Arclength]: ",
(false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_value), 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, _) => 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 { let default = match self.mode {
DistanceMode::Diameter => self.default_value * 2.0, DistanceMode::Diameter => self.default_value * 2.0,
DistanceMode::ArcLength if self.is_arc => self.default_arc_length, DistanceMode::ArcLength if self.is_arc => self.default_arc_length,
DistanceMode::X => self.default_x,
DistanceMode::Y => self.default_y,
_ => self.default_value, _ => 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<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
@@ -262,14 +317,29 @@ impl AngleConstraintCommand {
let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x); 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 a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x);
let default_value = (a2 - a1).to_degrees(); let default_value = (a2 - a1).to_degrees();
let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); let known_param_names = scene
Some(Self { fixed_handle: fixed, moving_handle: moving, default_value, known_param_names }) .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<CmdResult> { fn build(&self, target: DrivingValue) -> Option<CmdResult> {
if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) {
return None;
}
Some(CmdResult::AddSketchConstraint { Some(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Angle, 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), driving_param: Some(target),
label: "Angle constraint", label: "Angle constraint",
}) })
@@ -294,7 +364,8 @@ impl CadCommand for AngleConstraintCommand {
} }
fn on_enter(&mut self) -> CmdResult { 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<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
@@ -308,7 +379,9 @@ impl CadCommand for AngleConstraintCommand {
} }
// ── Autocomplete registry ───────────────────────────────── // ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DCONSTRAINT", "ACONSTRAINT"] }); inventory::submit!(crate::command::CommandRegistration {
names: &["DCONSTRAINT", "ACONSTRAINT"]
});
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -317,16 +390,25 @@ mod tests {
#[test] #[test]
fn parse_driving_value_prefers_a_numeric_literal() { fn parse_driving_value_prefers_a_numeric_literal() {
let known = vec!["hole_dia".to_string()]; 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 // Distance's own `build` rejects a non-positive target later; the
// parse step itself accepts any number, negative included. // 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] #[test]
fn parse_driving_value_recognizes_a_known_parameter_name() { fn parse_driving_value_recognizes_a_known_parameter_name() {
let known = vec!["hole_dia".to_string(), "plate_len".to_string()]; 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] #[test]
@@ -342,8 +424,14 @@ mod tests {
// here, so a lowercase-named parameter must still resolve — and // here, so a lowercase-named parameter must still resolve — and
// resolve to its canonically-cased name, not the uppercased token. // resolve to its canonically-cased name, not the uppercased token.
let known = vec!["hole_dia".to_string()]; let known = vec!["hole_dia".to_string()];
assert_eq!(parse_driving_value("HOLE_DIA", &known), Some(DrivingValue::Named("hole_dia".to_string()))); assert_eq!(
assert_eq!(parse_driving_value("Hole_Dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); 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 { fn add_line(scene: &mut Scene) -> Handle {
@@ -354,34 +442,47 @@ mod tests {
} }
fn add_circle(scene: &mut Scene) -> Handle { fn add_circle(scene: &mut Scene) -> Handle {
scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius( scene.add_entity(EntityType::Circle(
acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::entities::Circle::from_center_radius(
3.0, acadrust::types::Vector3::new(0.0, 0.0, 0.0),
))) 3.0,
),
))
} }
fn add_arc(scene: &mut Scene) -> Handle { fn add_arc(scene: &mut Scene) -> Handle {
scene.add_entity(EntityType::Arc(acadrust::entities::Arc::from_center_radius_angles( scene.add_entity(EntityType::Arc(
acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::entities::Arc::from_center_radius_angles(
3.0, acadrust::types::Vector3::new(0.0, 0.0, 0.0),
0.0, 3.0,
std::f64::consts::FRAC_PI_2, 0.0,
))) std::f64::consts::FRAC_PI_2,
),
))
} }
#[test] #[test]
fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let line = add_line(&mut scene); 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 // The keyword itself only switches mode and re-prompts — it must
// not build a constraint yet. // not build a constraint yet.
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); 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") { 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!(kind, ConstraintKind::DistanceX);
assert_eq!(v, 10.0); 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] #[test]
fn d_keyword_switches_a_circles_command_to_diameter_mode() { fn d_keyword_switches_a_circles_command_to_diameter_mode() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let circle = add_circle(&mut scene); 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!(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") { 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!(kind, ConstraintKind::Diameter);
assert_eq!(v, 16.0); assert_eq!(v, 16.0);
} }
@@ -411,10 +566,13 @@ mod tests {
fn default_mode_still_builds_a_plain_radius_constraint() { fn default_mode_still_builds_a_plain_radius_constraint() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let circle = add_circle(&mut scene); 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") { 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}}"), _ => panic!("expected an AddSketchConstraint{{Radius}}"),
} }
} }
@@ -423,13 +581,22 @@ mod tests {
fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { fn a_keyword_switches_an_arcs_command_to_arc_length_mode() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let arc = add_arc(&mut scene); 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!(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") { 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!(kind, ConstraintKind::ArcLength);
assert_eq!(v, 8.0); assert_eq!(v, 8.0);
} }
@@ -441,9 +608,14 @@ mod tests {
fn arc_length_keyword_is_not_offered_for_a_plain_circle() { fn arc_length_keyword_is_not_offered_for_a_plain_circle() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let circle = add_circle(&mut scene); 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 // "A" isn't a recognized keyword here and doesn't parse as a
// number or a named parameter either, so it's rejected outright. // number or a named parameter either, so it's rejected outright.
assert!(cmd.on_text_input("A").is_none()); assert!(cmd.on_text_input("A").is_none());
@@ -453,10 +625,15 @@ mod tests {
fn arc_length_default_is_the_arcs_current_swept_length() { fn arc_length_default_is_the_arcs_current_swept_length() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 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"); cmd.on_text_input("A");
let expected = 3.0 * std::f64::consts::FRAC_PI_2; 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()
);
} }
} }
File diff suppressed because it is too large. Load diff
+24 -28
View File
@@ -342,6 +342,7 @@ pub struct UndoRecording {
/// scope's `Vec` entry, once created, is never removed. /// scope's `Vec` entry, once created, is never removed.
sketch_constraints_before: HashMap<sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet>, sketch_constraints_before: HashMap<sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet>,
sketch_constraints_order: Vec<sketch_constraints::SketchScope>, sketch_constraints_order: Vec<sketch_constraints::SketchScope>,
named_parameters_before: Option<named_parameters::ParameterTable>,
poisoned: bool, poisoned: bool,
} }
@@ -351,12 +352,15 @@ impl UndoRecording {
self.poisoned 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 { 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 /// order. A `None` image means the entity/object was added by the
/// command; every sketch-constraint scope's before-image is a real /// command; every sketch-constraint scope's before-image is a real
/// `SketchConstraintSet` (its `Vec` entry, once created, is never /// `SketchConstraintSet` (its `Vec` entry, once created, is never
@@ -367,6 +371,7 @@ impl UndoRecording {
Vec<(Handle, Option<Arc<EntityType>>)>, Vec<(Handle, Option<Arc<EntityType>>)>,
Vec<(Handle, Option<ObjectType>)>, Vec<(Handle, Option<ObjectType>)>,
Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>, Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>,
Option<named_parameters::ParameterTable>,
) { ) {
let entities = self let entities = self
.order .order
@@ -383,7 +388,7 @@ impl UndoRecording {
.drain(..) .drain(..)
.filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before))) .filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before)))
.collect(); .collect();
(entities, objects, sketch_constraints) (entities, objects, sketch_constraints, self.named_parameters_before)
} }
/// Entity-only convenience used by the focused Scene delta tests. /// 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 /// Conservative association hint: unknown until scanned, then updated from
/// changed entities. Retaining `true` after deletion only costs an extra scan. /// changed entities. Retaining `true` after deletion only costs an extra scan.
has_associative_centers: std::cell::Cell<Option<bool>>, has_associative_centers: std::cell::Cell<Option<bool>>,
/// Persistent parametric constraint sets, one per active /// Persistent parametric constraint sets, one per sketch scope.
/// [`sketch_constraints::SketchScope`]. Lives on `Scene` (not /// Materialized into scoped XRecords during save and restored on open.
/// `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.
pub(crate) sketch_constraints: Vec<sketch_constraints::SketchConstraintSet>, pub(crate) sketch_constraints: Vec<sketch_constraints::SketchConstraintSet>,
/// Document-wide named-parameter/expression table (`docs/ /// Document-wide named-parameter and expression table.
/// named_parameters_design.md`). One table per document (not per-scope, /// Persisted through a single drawing XRecord.
/// unlike `sketch_constraints` — a parameter is meant to be shared
/// across the whole drawing), lives on `Scene` for the same reason
/// `sketch_constraints` does: co-located with the `document: CadDocument`
/// it's synced against. Synced with a single document-level
/// `OCS_NAMED_PARAMETERS` XRecord (hung off `document.header.
/// named_objects_dict_handle`, not a per-`BlockRecord` one) around
/// open/save (`named_parameters_persist.rs`, same "lazy" model as
/// `sketch_persist.rs`).
pub(crate) named_parameters: named_parameters::ParameterTable, pub(crate) named_parameters: named_parameters::ParameterTable,
/// Tessellated block definitions in block-local coords, keyed by render /// Tessellated block definitions in block-local coords, keyed by render
/// background and block epoch. Model and Paper adapt black/white colours /// 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 /// 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 /// group edit, a `*D` block record) that a pure-entity delta cannot
/// restore. The app's per-command predicate keeps this from firing. /// 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 handle = scene.add_entity(EntityType::RasterImage(image));
let rec = scene.take_undo_recording().unwrap(); let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned()); 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.len(), 1);
assert_eq!(entities[0].0, handle); assert_eq!(entities[0].0, handle);
assert_eq!(objects.len(), 1); assert_eq!(objects.len(), 1);
@@ -12147,7 +12143,7 @@ mod delta_undo_tests {
scene.erase_entities(&[h1]); scene.erase_entities(&[h1]);
let rec = scene.take_undo_recording().unwrap(); let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned()); 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.len(), 1);
assert_eq!(entities[0].0, h1); assert_eq!(entities[0].0, h1);
assert_eq!(objects.len(), 1); assert_eq!(objects.len(), 1);
+1 -4
View File
@@ -752,10 +752,7 @@ impl Scene {
.map(|&h| (h, ChangeKind::Added)) .map(|&h| (h, ChangeKind::Added))
.collect(); .collect();
self.bump_entities(&changes); self.bump_entities(&changes);
// Design doc §12: a constraint entirely between duplicated entities // Constraints wholly within the duplicated selection follow it.
// 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.
self.duplicate_sketch_constraints_for(&handle_map); self.duplicate_sketch_constraints_for(&handle_map);
self.refresh_meshes_for_handles(&refresh_solid_handles); self.refresh_meshes_for_handles(&refresh_solid_handles);
new_handles new_handles
+155 -95
View File
@@ -1,32 +1,5 @@
//! Named, formula-capable document parameters — data model. //! Named, formula-capable parameters stored per drawing.
//! //! Formulas are parsed locally so dependency cycles can be rejected before evaluation.
//! Design: `docs/named_parameters_design.md`. This is stage 1 of that doc's
//! §4 staged plan: the parameter table type plus a small expression
//! parser/evaluator, unit-tested in isolation — no document/UI integration
//! yet (that's stage 2's XRecord persistence, mirroring
//! [`super::sketch_persist`], and stage 3's
//! `SketchConstraint::driving_param` wiring).
//!
//! A [`Parameter`] is a name plus a formula: either a bare literal (`12.5`)
//! or an expression referencing other parameters by name (`2 * hole_dia +
//! 1.5`). A [`ParameterTable`] holds every parameter for one document (per
//! the design doc's §2: one table per document, not per-[`super::
//! sketch_constraints::SketchScope`]) and resolves each one's numeric value,
//! rejecting a circular reference at the moment it would be introduced
//! rather than at evaluation time — the design doc's explicit requirement
//! ("A referencing B referencing A must be rejected, not silently
//! infinite-loop or panic"), and the same real-time-rejection behavior a
//! spreadsheet gives for a circular formula.
//!
//! No existing workspace dependency parses expressions (the design doc's §2
//! flags checking `evalexpr`/`meval` before hand-rolling one). Hand-rolled
//! here instead: cycle detection needs the set of names an expression
//! references *before* it's evaluated, which means owning the parse tree —
//! treating evaluation as a black box, the way a generic expression-eval
//! crate is normally used, would just mean re-deriving that same dependency
//! information by other means. [`Expr::refs`] *is* that dependency
//! information, not extra work spent duplicating what such a crate computes
//! internally anyway.
use super::Scene; use super::Scene;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -34,12 +7,8 @@ use std::collections::HashMap;
use std::fmt; use std::fmt;
/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to /// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to
/// another parameter by name — what makes this a *named*-parameter system /// another parameter by name) and [`Expr::Call`] (a function call). Only the
/// rather than a plain calculator) and [`Expr::Call`] (a function call). /// small builtin set in `call_builtin` is accepted.
/// `Call` is in the grammar now so it doesn't need a rewrite if/when more
/// functions are wanted later (design doc §5 open question 2 leaves the
/// exact v1 function set undecided); only a small, conservative builtin set
/// is actually wired up for now (see `call_builtin`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Expr { pub enum Expr {
Number(f64), Number(f64),
@@ -66,7 +35,11 @@ impl Expr {
} }
} }
Expr::Neg(a) => a.refs(out), 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); a.refs(out);
b.refs(out); b.refs(out);
} }
@@ -98,7 +71,11 @@ pub enum ParamError {
/// `b = a`. /// `b = a`.
Cycle(Vec<String>), Cycle(Vec<String>),
UnknownFunction(String), 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 /// Raised by `resolve`/`resolve_all`, not `set` — a formula may
/// legitimately reference a parameter that doesn't exist *yet* (order of /// legitimately reference a parameter that doesn't exist *yet* (order of
/// definition shouldn't matter, mirroring a spreadsheet); this only /// 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::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"),
ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")), ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")),
ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"), ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"),
ParamError::WrongArgCount { function, expected, found } => { ParamError::WrongArgCount {
write!(f, "{function}() expects {expected} argument(s), found {found}") function,
expected,
found,
} => {
write!(
f,
"{function}() expects {expected} argument(s), found {found}"
)
} }
ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"), ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"),
ParamError::DivisionByZero => write!(f, "division by zero"), ParamError::DivisionByZero => write!(f, "division by zero"),
@@ -125,9 +109,8 @@ impl fmt::Display for ParamError {
impl std::error::Error for ParamError {} impl std::error::Error for ParamError {}
/// The v1 builtin function set — deliberately small (design doc §5 open /// The builtin function set. It also supplies the reserved-word list that
/// question 2 defers the full surface). Also doubles as the reserved-word /// `is_reserved_name` checks a parameter name against, so a formula
/// list `is_reserved_name` checks a parameter name against, so a formula
/// like `sqrt(x)` is never ambiguous between "call the builtin" and /// like `sqrt(x)` is never ambiguous between "call the builtin" and
/// "multiply by a parameter named sqrt". /// "multiply by a parameter named sqrt".
const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"]; 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 /// 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 { pub fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars(); let mut chars = name.chars();
match chars.next() { match chars.next() {
@@ -147,12 +130,7 @@ pub fn is_valid_name(name: &str) -> bool {
chars.all(|c| c.is_alphanumeric() || c == '_') chars.all(|c| c.is_alphanumeric() || c == '_')
} }
/// A dimensional constraint's driving value (design doc §2's own suggested /// A dimensional constraint's literal or named driving value.
/// shape for `SketchConstraint::driving_param`, stage 3 of the named-
/// parameters staged plan): either a literal number typed directly, or a
/// reference to a named parameter resolved through a [`ParameterTable`] at
/// solve time. Lives here rather than in `sketch_constraints.rs` since
/// resolving `Named` needs `ParameterTable`, which this module owns.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DrivingValue { pub enum DrivingValue {
Literal(f64), Literal(f64),
@@ -182,7 +160,11 @@ impl From<f64> for DrivingValue {
fn call_builtin(name: &str, args: &[f64]) -> Result<f64, ParamError> { fn call_builtin(name: &str, args: &[f64]) -> Result<f64, ParamError> {
fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> { fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> {
if args.len() != expected { 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(()) Ok(())
} }
@@ -302,7 +284,9 @@ fn tokenize(src: &str) -> Result<Vec<Token>, ParamError> {
} }
} }
let text: String = chars[start..i].iter().collect(); 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)); tokens.push(Token::Number(value));
} }
_ if c.is_alphabetic() || c == '_' => { _ if c.is_alphabetic() || c == '_' => {
@@ -340,8 +324,12 @@ impl<'a> Parser<'a> {
fn expect(&mut self, expected: Token) -> Result<(), ParamError> { fn expect(&mut self, expected: Token) -> Result<(), ParamError> {
match self.bump() { match self.bump() {
Some(t) if t == expected => Ok(()), Some(t) if t == expected => Ok(()),
Some(t) => Err(ParamError::Parse(format!("expected {expected:?}, found {t:?}"))), Some(t) => Err(ParamError::Parse(format!(
None => Err(ParamError::Parse(format!("expected {expected:?}, found end of formula"))), "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<Expr, ParamError> {
if tokens.is_empty() { if tokens.is_empty() {
return Err(ParamError::Parse("empty formula".to_string())); 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()?; let expr = parser.parse_expr()?;
if parser.pos != tokens.len() { 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) Ok(expr)
} }
@@ -471,17 +465,14 @@ pub struct Parameter {
pub expr: Expr, pub expr: Expr,
} }
/// Every named parameter for one document (design doc §2: per-document, not /// Every named parameter for one document.
/// per-[`super::sketch_constraints::SketchScope`] — a block/xref does not
/// get its own independent table; open question 4 leaves whether that's the
/// final answer, but it's this stage's working assumption).
/// ///
/// `parameters` is intentionally not `pub` (unlike the sibling /// `parameters` is intentionally not `pub` (unlike the sibling
/// `SketchConstraintSet::constraints`): this table's entire value is that /// `SketchConstraintSet::constraints`): this table's entire value is that
/// every stored formula is already known to be parse-valid and cycle-free, /// every stored formula is already known to be parse-valid and cycle-free,
/// an invariant only `set`/`remove` maintain. A direct external push could /// an invariant only `set`/`remove` maintain. A direct external push could
/// silently violate it. /// silently violate it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ParameterTable { pub struct ParameterTable {
parameters: Vec<Parameter>, parameters: Vec<Parameter>,
} }
@@ -523,10 +514,8 @@ impl ParameterTable {
/// entry point; a future UI stage that wants add-vs-rename semantics can /// entry point; a future UI stage that wants add-vs-rename semantics can
/// check `contains` itself first). /// check `contains` itself first).
/// ///
/// Rejects: an invalid identifier or one colliding with a builtin /// Rejects an invalid or reserved identifier, malformed formula, or
/// function name (`is_valid_name`/`is_reserved_name`), a formula that /// circular reference. A reference
/// fails to parse, and — the design doc's explicit requirement — a
/// formula that would close a circular reference right now. A reference
/// to a parameter that doesn't exist *yet* is not rejected (order of /// to a parameter that doesn't exist *yet* is not rejected (order of
/// definition shouldn't matter, mirroring a spreadsheet); it only /// definition shouldn't matter, mirroring a spreadsheet); it only
/// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`] /// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`]
@@ -542,7 +531,11 @@ impl ParameterTable {
self.parameters[i].source = source.to_string(); self.parameters[i].source = source.to_string();
self.parameters[i].expr = expr; 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(()) Ok(())
} }
@@ -589,7 +582,12 @@ impl ParameterTable {
/// Depth-first search from `current` to `target`, following each /// Depth-first search from `current` to `target`, following each
/// visited parameter's existing formula's references. Returns the path /// visited parameter's existing formula's references. Returns the path
/// (starting with `current`) if `target` is reachable. /// (starting with `current`) if `target` is reachable.
fn find_path(&self, current: &str, target: &str, visiting: &mut Vec<String>) -> Option<Vec<String>> { fn find_path(
&self,
current: &str,
target: &str,
visiting: &mut Vec<String>,
) -> Option<Vec<String>> {
if current == target { if current == target {
return Some(vec![current.to_string()]); return Some(vec![current.to_string()]);
} }
@@ -617,12 +615,8 @@ impl ParameterTable {
None None
} }
/// Resolves one parameter's numeric value, evaluating its dependency /// Resolves one parameter's numeric value and dependency chain using a
/// chain as needed. No state is kept between calls (a fresh memo cache /// fresh memo cache.
/// every call) — mirrors `refresh_sketch_constraints`'s
/// rebuild-from-scratch-per-trigger model (design doc §2): this stage
/// has no document/UI integration yet to decide when a cached value
/// would go stale, so it simply never caches across calls.
pub fn resolve(&self, name: &str) -> Result<f64, ParamError> { pub fn resolve(&self, name: &str) -> Result<f64, ParamError> {
let mut cache = HashMap::new(); let mut cache = HashMap::new();
let mut stack = Vec::new(); let mut stack = Vec::new();
@@ -642,7 +636,12 @@ impl ParameterTable {
out out
} }
fn resolve_inner(&self, name: &str, cache: &mut HashMap<String, f64>, stack: &mut Vec<String>) -> Result<f64, ParamError> { fn resolve_inner(
&self,
name: &str,
cache: &mut HashMap<String, f64>,
stack: &mut Vec<String>,
) -> Result<f64, ParamError> {
if let Some(v) = cache.get(name) { if let Some(v) = cache.get(name) {
return Ok(*v); return Ok(*v);
} }
@@ -654,7 +653,9 @@ impl ParameterTable {
cyc.push(name.to_string()); cyc.push(name.to_string());
return Err(ParamError::Cycle(cyc)); 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()); stack.push(name.to_string());
let value = self.eval(&param.expr, cache, stack)?; let value = self.eval(&param.expr, cache, stack)?;
stack.pop(); stack.pop();
@@ -662,7 +663,12 @@ impl ParameterTable {
Ok(value) Ok(value)
} }
fn eval(&self, expr: &Expr, cache: &mut HashMap<String, f64>, stack: &mut Vec<String>) -> Result<f64, ParamError> { fn eval(
&self,
expr: &Expr,
cache: &mut HashMap<String, f64>,
stack: &mut Vec<String>,
) -> Result<f64, ParamError> {
Ok(match expr { Ok(match expr {
Expr::Number(n) => *n, Expr::Number(n) => *n,
Expr::Ref(name) => self.resolve_inner(name, cache, stack)?, Expr::Ref(name) => self.resolve_inner(name, cache, stack)?,
@@ -677,7 +683,9 @@ impl ParameterTable {
} }
self.eval(a, cache, stack)? / denom 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) => { Expr::Call(fname, args) => {
let mut values = Vec::with_capacity(args.len()); let mut values = Vec::with_capacity(args.len());
for arg in args { for arg in args {
@@ -690,8 +698,8 @@ impl ParameterTable {
} }
impl Scene { impl Scene {
/// The document's named-parameter table (design doc §2: one per /// The document's named-parameter table. Use
/// document). Read-only accessor; use [`Scene::named_parameters_mut`] to /// [`Scene::named_parameters_mut`] to
/// define/redefine/remove a parameter. /// define/redefine/remove a parameter.
pub fn named_parameters(&self) -> &ParameterTable { pub fn named_parameters(&self) -> &ParameterTable {
&self.named_parameters &self.named_parameters
@@ -726,7 +734,9 @@ mod tests {
fn eval_literal(src: &str) -> f64 { fn eval_literal(src: &str) -> f64 {
let table = ParameterTable::new(); let table = ParameterTable::new();
let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}")); 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] #[test]
@@ -754,11 +764,22 @@ mod tests {
let table = ParameterTable::new(); let table = ParameterTable::new();
let expr = parse("sqrt(1, 2)").unwrap(); let expr = parse("sqrt(1, 2)").unwrap();
let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); let err = table
assert_eq!(err, ParamError::WrongArgCount { function: "sqrt".to_string(), expected: 1, found: 2 }); .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 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())); assert_eq!(err, ParamError::UnknownFunction("bogus".to_string()));
} }
@@ -766,16 +787,31 @@ mod tests {
fn division_by_zero_is_reported_not_panicked() { fn division_by_zero_is_reported_not_panicked() {
let table = ParameterTable::new(); let table = ParameterTable::new();
let expr = parse("1 / 0").unwrap(); 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] #[test]
fn set_rejects_invalid_and_reserved_names() { fn set_rejects_invalid_and_reserved_names() {
let mut table = ParameterTable::new(); let mut table = ParameterTable::new();
assert!(matches!(table.set("2bad", "1"), Err(ParamError::InvalidName(_)))); assert!(matches!(
assert!(matches!(table.set("has space", "1"), Err(ParamError::InvalidName(_)))); table.set("2bad", "1"),
assert!(matches!(table.set("", "1"), Err(ParamError::InvalidName(_)))); Err(ParamError::InvalidName(_))
assert!(matches!(table.set("sqrt", "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()); assert!(table.set("_valid1", "1").is_ok());
} }
@@ -800,7 +836,10 @@ mod tests {
fn resolving_an_undefined_reference_reports_which_name_is_missing() { fn resolving_an_undefined_reference_reports_which_name_is_missing() {
let mut table = ParameterTable::new(); let mut table = ParameterTable::new();
table.set("a", "b + 1").unwrap(); 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] #[test]
@@ -808,7 +847,10 @@ mod tests {
let mut table = ParameterTable::new(); let mut table = ParameterTable::new();
let err = table.set("a", "a + 1").unwrap_err(); let err = table.set("a", "a + 1").unwrap_err();
assert!(matches!(err, ParamError::Cycle(_))); 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] #[test]
@@ -816,7 +858,10 @@ mod tests {
let mut table = ParameterTable::new(); let mut table = ParameterTable::new();
table.set("a", "b").unwrap(); table.set("a", "b").unwrap();
// b doesn't exist yet -- not a cycle, a is just unresolved for now. // 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, // Defining b = a closes the loop right here and must be rejected,
// leaving b undefined rather than silently accepted. // leaving b undefined rather than silently accepted.
let err = table.set("b", "a").unwrap_err(); let err = table.set("b", "a").unwrap_err();
@@ -824,7 +869,10 @@ mod tests {
assert!(!table.contains("b")); assert!(!table.contains("b"));
// a's formula (still "b") is untouched -- it stays unresolved, not // a's formula (still "b") is untouched -- it stays unresolved, not
// corrupted by the rejected attempt. // 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] #[test]
@@ -849,8 +897,14 @@ mod tests {
let results = table.resolve_all(); let results = table.resolve_all();
assert_eq!(results.get("good"), Some(&Ok(10.0))); assert_eq!(results.get("good"), Some(&Ok(10.0)));
assert_eq!(results.get("bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); assert_eq!(
assert_eq!(results.get("depends_on_bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); 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] #[test]
@@ -859,8 +913,14 @@ mod tests {
table.set("a", "1").unwrap(); table.set("a", "1").unwrap();
table.set("b", "a + 1").unwrap(); table.set("b", "a + 1").unwrap();
assert!(table.remove("a")); assert!(table.remove("a"));
assert!(!table.remove("a"), "removing an already-removed name reports false"); assert!(
assert_eq!(table.resolve("b"), Err(ParamError::UndefinedReference("a".to_string()))); !table.remove("a"),
"removing an already-removed name reports false"
);
assert_eq!(
table.resolve("b"),
Err(ParamError::UndefinedReference("a".to_string()))
);
} }
#[test] #[test]
+55 -59
View File
@@ -1,50 +1,8 @@
//! Save/load integration for [`super::named_parameters::ParameterTable`] — //! Persists the drawing-wide named-parameter table in a versioned XRecord.
//! design doc §4 stage 2 (the XRecord persistence spike) plus enough of
//! stage 2's "wire it into open/save" to actually be useful, following
//! [`super::sketch_persist`]'s exact precedent (itself the sketch-constraint
//! project's stage 4/§5.2): the "lazy" model, where the table lives only in
//! `Scene::named_parameters` in-memory during editing and is materialized
//! into `document.objects` as one XRecord right before a save, then read
//! back right after a load.
//!
//! The one structural difference from `sketch_persist`: a
//! [`super::named_parameters::ParameterTable`] is document-wide, not
//! per-[`super::sketch_constraints::SketchScope`], so there is exactly one
//! XRecord for the whole document rather than one per scope owner.
//!
//! **Owner handle, and a real round-trip gap found while building this**:
//! the obvious choice was `document.header.named_objects_dict_handle` (the
//! document's own root Named Objects Dictionary) — `extension_dictionary_
//! handle` in the vendored `cadcodec` source explicitly resolves an
//! extension dictionary for a `Dictionary`-type owner (`ObjectType::
//! Dictionary(value) => value.xdictionary_handle`), and that crate's own
//! tests (e.g. `tests/issue51.rs`) use `named_objects_dict_handle` as an
//! XRecord owner directly. In practice this round-tripped through DWG but
//! **silently lost the XRecord through DXF** (caught by this module's own
//! save/load tests, not assumed): `CadDocument::ensure_xrecord` only writes
//! the owner's *own* `xdictionary_handle` field when `get_entity_mut(owner)`
//! succeeds (a real "entity"); a `Dictionary` owner like the root NOD isn't
//! one, so `ensure_xrecord` falls back to a runtime-only side map
//! (`xdic_by_handle`) instead. The DWG writer consults that side map
//! (`io/dwg/dwg_document_builder.rs` et al.); the DXF writer never does (no
//! `xdic_by_handle` reference anywhere under `io/dxf/writer/`) — so a
//! Dictionary-owned extension dictionary is invisible to a DXF save. This
//! is a gap in the vendored crate for this specific owner-type combination,
//! not something introduced here; per this project's own stated preference
//! to avoid needing changes to `acadrust`/`cadkernel`, the fix is to pick a
//! different, already-proven owner rather than patch it.
//!
//! **Owner used instead**: `document.header.model_space_block_handle` — a
//! real `BlockRecord` entity, the exact anchor `sketch_persist`'s own
//! `SketchScope::ModelSpace` case already proved round-trips cleanly
//! through both DWG and DXF. Model space is not a semantically perfect fit
//! for "the whole document" (a parameter table has nothing to do with model
//! space specifically), but it is guaranteed to exist in every
//! `CadDocument` and is a real entity, which is the property that actually
//! matters here.
use super::named_parameters::ParameterTable; use super::named_parameters::ParameterTable;
use super::Scene; use super::Scene;
use bincode::Options;
const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; 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 /// 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. /// multiple same-code `310` entries on write and concatenated back on read.
const MAX_CHUNK_BYTES: usize = u8::MAX as usize; 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 /// Prefixed onto the serialized blob so a future schema change can be
/// detected and gracefully skipped rather than silently misreading bytes — /// detected and gracefully skipped rather than silently misreading bytes —
@@ -62,7 +21,11 @@ const FORMAT_VERSION: u8 = 1;
fn encode(table: &ParameterTable) -> Option<Vec<u8>> { fn encode(table: &ParameterTable) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION]; 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) Some(bytes)
} }
@@ -71,7 +34,12 @@ fn decode(bytes: &[u8]) -> Option<ParameterTable> {
if version != FORMAT_VERSION { if version != FORMAT_VERSION {
return None; 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 { impl Scene {
@@ -89,7 +57,9 @@ impl Scene {
if self.named_parameters.is_empty() { if self.named_parameters.is_empty() {
return; 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; let owner = self.document.header.model_space_block_handle;
if owner.is_null() { if owner.is_null() {
return; return;
@@ -100,9 +70,10 @@ impl Scene {
// not accumulate more Chunk entries every time. // not accumulate more Chunk entries every time.
record.entries.clear(); record.entries.clear();
for chunk in bytes.chunks(MAX_CHUNK_BYTES) { for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
record record.entries.push(acadrust::objects::XRecordEntry::new(
.entries 310,
.push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(chunk.to_vec()))); acadrust::objects::XRecordValue::Chunk(chunk.to_vec()),
));
} }
} }
} }
@@ -121,12 +92,18 @@ impl Scene {
if owner.is_null() { if owner.is_null() {
return; 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 // Concatenate every Chunk entry in order, not just the first — see
// `MAX_CHUNK_BYTES`'s doc comment. // `MAX_CHUNK_BYTES`'s doc comment.
let mut bytes = Vec::new(); let mut bytes = Vec::new();
for entry in &record.entries { for entry in &record.entries {
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { 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); bytes.extend_from_slice(chunk);
} }
} }
@@ -147,7 +124,10 @@ mod tests {
fn a_parameter_table_survives_materialize_and_reload() { fn a_parameter_table_survives_materialize_and_reload() {
let mut scene = Scene::new(); let mut scene = Scene::new();
scene.named_parameters_mut().set("hole_dia", "5").unwrap(); 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(); scene.materialize_named_parameters_for_save();
// Simulate the load-time side of a save/reload round trip: drop the // 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().len(), 2);
assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5)); 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 /// The above test only proves `materialize`/`load` agree with each
@@ -169,15 +152,22 @@ mod tests {
/// `sketch_persist`'s own real-bytes tests. /// `sketch_persist`'s own real-bytes tests.
fn full_bytes_roundtrip(ext: &str) -> ParameterTable { fn full_bytes_roundtrip(ext: &str) -> ParameterTable {
let mut scene = Scene::new(); let mut scene = Scene::new();
scene.named_parameters_mut().set("plate_len", "42.5").unwrap(); scene
scene.named_parameters_mut().set("plate_width", "plate_len / 2").unwrap(); .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(); scene.materialize_named_parameters_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
let mut reloaded_scene = Scene::new(); let mut reloaded_scene = Scene::new();
reloaded_scene.document = crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) reloaded_scene.document =
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); 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.load_named_parameters_from_document();
reloaded_scene.named_parameters reloaded_scene.named_parameters
@@ -202,13 +192,19 @@ mod tests {
let mut scene = Scene::new(); let mut scene = Scene::new();
scene.materialize_named_parameters_for_save(); scene.materialize_named_parameters_for_save();
let owner = scene.document.header.model_space_block_handle; 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] #[test]
fn decode_rejects_a_mismatched_format_version() { fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap()); 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"
);
} }
} }
+1 -1
View File
@@ -2492,7 +2492,6 @@ mod texture_limit_tests {
let rgba = vec![0u8; 10]; let rgba = vec![0u8; 10];
assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none()); assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none());
} }
#[test] #[test]
fn malformed_material_images_are_rejected_even_below_the_texture_limit() { 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])] { 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()); assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none());
} }
} }
+271 -165
View File
@@ -1,15 +1,4 @@
//! Persistent, parametric geometric constraints — data model. //! Persistent sketch-constraint data types and scope management.
//!
//! 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.
use super::named_parameters::DrivingValue; use super::named_parameters::DrivingValue;
use acadrust::types::{Handle, Vector3}; use acadrust::types::{Handle, Vector3};
@@ -40,23 +29,32 @@ pub struct SketchRef {
impl SketchRef { impl SketchRef {
pub fn whole(entity: Handle) -> Self { pub fn whole(entity: Handle) -> Self {
Self { entity, marker: None } Self {
entity,
marker: None,
}
} }
pub fn point(entity: Handle, marker: i32) -> Self { 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 /// The circle/arc-center special case (`marker == -3`), broken out as
/// its own constructor since `-3` alone reads as a magic number /// its own constructor since `-3` alone reads as a magic number
/// everywhere it would otherwise appear. /// everywhere it would otherwise appear.
pub fn center(entity: Handle) -> Self { 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 /// 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 /// 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 /// doc §2). Named and grouped the same way the existing one-shot ribbon
/// tools are (`crate::modules::draw::constrain::tools`), plus the /// tools are (`crate::modules::draw::constrain::tools`), plus the
@@ -102,11 +100,8 @@ pub enum ConstraintKind {
PointOnCurve, PointOnCurve,
/// The distance between one point pair equals the distance between /// The distance between one point pair equals the distance between
/// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`).
/// AutoCAD's fourth `Equal` sub-kind (`ACEQUALDISTANCECONSTRAINT`) — /// For the supported entity types, equal curvature is already covered
/// see the design doc for why `EqualCurvature`, the other missing /// by the circle/circle radius branch of `Equal`.
/// sub-kind, isn't modeled: for the only entity types this system
/// solves (Line, Circle), it would be mathematically identical to this
/// `Equal`'s existing circle/circle (radius) branch.
EqualDistance, EqualDistance,
/// Two circles/arcs are mirror images of each other across a line — /// Two circles/arcs are mirror images of each other across a line —
/// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the /// `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` /// `[whole(circle_or_arc)]`. Same DWG class as `Radius`
/// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the
/// `RadiusDiameterConstrType` mode byte /// `RadiusDiameterConstrType` mode byte
/// (`dwg_native_constraints.rs`), matching how real AutoCAD represents /// (`dwg_native_constraints.rs`), rather than a distinct object type.
/// it — not a distinct native object type.
Diameter, Diameter,
/// The X-only (resp. Y-only) component of the distance between two /// The X-only (resp. Y-only) component of the distance between two
/// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG
/// class as `Distance` (`ACDISTANCECONSTRAINT`) with its /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its
/// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction, again /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction.
/// matching AutoCAD's own representation rather than inventing a new
/// class.
DistanceX, DistanceX,
DistanceY, DistanceY,
/// A line perpendicular to a circle/arc's tangent at their point of /// 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 /// passes through the circle's center" (a circle's radius is always
/// normal to its own tangent), so it solves via the same `PointOnLine` /// normal to its own tangent), so it solves via the same `PointOnLine`
/// primitive `PointOnCurve` already uses. Distinct from /// primitive `PointOnCurve` already uses. Distinct from
/// `Perpendicular` (line-to-line only) — AutoCAD's own /// `Perpendicular` (line-to-line only). Line-Line has no meaning here
/// `GeomConstraintType` enum lists `kNormal` and `kPerpendicular`
/// separately for exactly this reason. Line-Line has no meaning here
/// (that's plain `Perpendicular`) and isn't buildable. /// (that's plain `Perpendicular`) and isn't buildable.
Normal, Normal,
/// An arc's arc length (`radius * sweep angle`) — `refs`: /// An arc's arc length (`radius * sweep angle`) — `refs`:
@@ -164,8 +154,7 @@ pub struct SketchConstraint {
pub refs: Vec<SketchRef>, pub refs: Vec<SketchRef>,
/// The target for a dimensional constraint (a `Distance`'s length, an /// The target for a dimensional constraint (a `Distance`'s length, an
/// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a /// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a
/// named-parameter reference (`docs/named_parameters_design.md`, /// named-parameter reference resolved through `Scene::named_parameters` at solve time by
/// resolved through `Scene::named_parameters` at solve time by
/// `sketch_solve::build_constraint`). `None` for every purely-geometric /// `sketch_solve::build_constraint`). `None` for every purely-geometric
/// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, /// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular,
/// Equal, Tangent). /// Equal, Tangent).
@@ -175,10 +164,7 @@ pub struct SketchConstraint {
pub enabled: bool, pub enabled: bool,
} }
/// What "one sketch" scopes to, given the app has no dedicated Sketch /// A constraint scope: model space or one block definition.
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SketchScope { pub enum SketchScope {
ModelSpace, ModelSpace,
@@ -189,12 +175,7 @@ pub enum SketchScope {
} }
impl SketchScope { impl SketchScope {
/// The handle a [`SketchConstraintSet`] for this scope is persisted /// The owner handle under which 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.
pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle { pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle {
match self { match self {
SketchScope::ModelSpace => document.header.model_space_block_handle, SketchScope::ModelSpace => document.header.model_space_block_handle,
@@ -203,9 +184,8 @@ impl SketchScope {
} }
} }
/// Every persisted constraint for one [`SketchScope`]. Rebuilt-and-solved /// Every persisted constraint for one [`SketchScope`]. Solver state is rebuilt
/// wholesale on every trigger (design doc §4.2) — this struct holds only /// on demand and is not stored here.
/// the constraint records themselves, no solver state.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SketchConstraintSet { pub struct SketchConstraintSet {
pub scope: SketchScope, pub scope: SketchScope,
@@ -215,30 +195,49 @@ pub struct SketchConstraintSet {
/// independent solve partition in this scope — updated by /// independent solve partition in this scope — updated by
/// `sketch_solve::solve_scope` each time this set is resolved. `None` /// `sketch_solve::solve_scope` each time this set is resolved. `None`
/// until the first resolve (e.g. right after loading from disk, before /// 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 /// any edit has touched this scope yet). The status badge reads this
/// reads this rather than recomputing it every frame. Not persisted: /// rather than recomputing it every frame. Not persisted:
/// it's a derived cache, not real constraint state. /// it's a derived cache, not real constraint state.
#[serde(skip)] #[serde(skip)]
pub dof: Option<usize>, pub dof: Option<usize>,
/// Cached redundant/conflicting constraints found by the last resolve /// Cached redundant/conflicting constraints found by the last resolve.
/// (design doc §6.4, stage 11) — empty whenever the scope isn't /// Also a derived cache, not
/// over-constrained, which is the common case. Also a derived cache, not
/// persisted; a `ConflictResolverPanel` reads this rather than calling /// persisted; a `ConflictResolverPanel` reads this rather than calling
/// `ocs_gcs::diagnosis::classify_redundant` itself. /// `cadkernel_constraints::diagnosis::classify_redundant` itself.
#[serde(skip)] #[serde(skip)]
pub conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>, pub conflicts: Vec<(
ConstraintId,
cadkernel_constraints::diagnosis::RedundancyKind,
)>,
} }
impl SketchConstraintSet { impl SketchConstraintSet {
pub fn new(scope: SketchScope) -> Self { 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. /// Appends a constraint, assigning it a fresh id unique within this set.
pub fn add(&mut self, kind: ConstraintKind, refs: Vec<SketchRef>, driving_param: Option<DrivingValue>) -> ConstraintId { pub fn add(
&mut self,
kind: ConstraintKind,
refs: Vec<SketchRef>,
driving_param: Option<DrivingValue>,
) -> ConstraintId {
let id = self.next_id; let id = self.next_id;
self.next_id += 1; 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 id
} }
@@ -253,28 +252,27 @@ impl SketchConstraintSet {
self.constraints.iter().find(|c| c.id == id) self.constraints.iter().find(|c| c.id == id)
} }
/// Every enabled constraint referencing `entity`, regardless of which /// Every enabled constraint referencing `entity`.
/// of its sub-elements — the query `refresh_sketch_constraints`'
/// reverse index (design doc §4.1) is built from.
pub fn constraints_touching(&self, entity: Handle) -> impl Iterator<Item = &SketchConstraint> { pub fn constraints_touching(&self, entity: Handle) -> impl Iterator<Item = &SketchConstraint> {
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 /// Drops every constraint that references `entity` and returns the
/// dangling-constraint-on-delete policy (design doc §5.3, open question /// removed constraint ids.
/// 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.
pub fn remove_all_touching(&mut self, entity: Handle) -> Vec<ConstraintId> { pub fn remove_all_touching(&mut self, entity: Handle) -> Vec<ConstraintId> {
let (removed, kept): (Vec<_>, Vec<_>) = let (removed, kept): (Vec<_>, Vec<_>) = self
self.constraints.drain(..).partition(|c| c.refs.iter().any(|r| r.entity == entity)); .constraints
.drain(..)
.partition(|c| c.refs.iter().any(|r| r.entity == entity));
self.constraints = kept; self.constraints = kept;
removed.into_iter().map(|c| c.id).collect() removed.into_iter().map(|c| c.id).collect()
} }
} }
/// Resolves a [`SketchRef`] to its current world-space point, for building /// 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 /// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted
/// to the marker conventions constraint endpoints actually use (whole-entity /// to the marker conventions constraint endpoints actually use (whole-entity
/// `None`, an ordinary `source_points()` index, or the `-3` center case). /// `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 /// marker this scheme doesn't (yet) support (e.g. `-2`, or an out-of-range
/// index). /// index).
/// ///
/// `sketch_solve`'s solver-side entity registration reads entity fields /// Solver-side registration reads raw entity fields directly. This helper is
/// directly rather than through this (it needs raw, not-yet-WCS-resolved /// for UI-side consumers that need the current world-space position.
/// 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".
pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option<Vector3> { pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option<Vector3> {
if marker == -3 { if marker == -3 {
return match entity { return match entity {
@@ -298,7 +293,9 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio
if marker < 0 { if marker < 0 {
return None; 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 /// 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. /// purposes.
const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12;
/// Design doc §6.1's manual Coincident UI: given a world point (from a /// Finds the addressable entity point nearest a snapped world position.
/// `CadCommand::on_point` pick — trusted to already be OSNAP-snapped onto a /// Returns `None` when no point in the scope is within the coincidence
/// real feature, the same trust `infer_coincident_refs` places in a /// tolerance.
/// 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.
pub(crate) fn nearest_sketch_point( pub(crate) fn nearest_sketch_point(
document: &acadrust::CadDocument, document: &acadrust::CadDocument,
scope: SketchScope, scope: SketchScope,
@@ -338,7 +328,10 @@ pub(crate) fn nearest_sketch_point(
if common.owner_handle != owner || Some(common.handle) == exclude { if common.owner_handle != owner || Some(common.handle) == exclude {
continue; 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); consider(common.handle, marker as i32, point);
} }
match candidate { match candidate {
@@ -350,23 +343,13 @@ pub(crate) fn nearest_sketch_point(
best.map(|(_, r)| r) best.map(|(_, r)| r)
} }
/// Design doc §6.2 (stage 8, Phase B live inference): for each of /// For each point on `new_entity`, finds an existing entity in `scope` (other
/// `new_entity`'s own points, finds an existing entity in `scope` (other
/// than `new_handle` itself) whose corresponding point already coincides, /// than `new_handle` itself) whose corresponding point already coincides,
/// and returns a `(new, existing)` `SketchRef` pair per match — the caller /// and returns a `(new, existing)` `SketchRef` pair per match — the caller
/// adds each as a `Coincident` constraint. /// adds each as a `Coincident` constraint.
/// ///
/// **Deviation from this doc**: the doc's own §6.2 suggests reusing OSNAP's /// Matching uses a tight world-space tolerance because this runs after the
/// screen-space tolerance/query; this instead checks for near-exact /// snapped entity has been committed.
/// 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.
pub(crate) fn infer_coincident_refs( pub(crate) fn infer_coincident_refs(
document: &acadrust::CadDocument, document: &acadrust::CadDocument,
scope: SketchScope, scope: SketchScope,
@@ -385,12 +368,18 @@ pub(crate) fn infer_coincident_refs(
if common.handle == new_handle || common.owner_handle != owner { if common.handle == new_handle || common.owner_handle != owner {
continue; 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 dx = point.x - new_point.x;
let dy = point.y - new_point.y; let dy = point.y - new_point.y;
let dz = point.z - new_point.z; let dz = point.z - new_point.z;
if dx * dx + dy * dy + dz * dz <= COINCIDENT_EPSILON_SQ { 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; break;
} }
} }
@@ -437,8 +426,12 @@ impl ConstraintKind {
/// value for a dimensional kind (Distance/Angle/Radius). /// value for a dimensional kind (Distance/Angle/Radius).
pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String {
match (constraint.kind, &constraint.driving_param) { match (constraint.kind, &constraint.driving_param) {
(ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()), (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => {
(_, Some(DrivingValue::Literal(value))) => format!("{} {value:.2}", constraint.kind.glyph_symbol()), 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 // A named reference has no single resolved number to show without
// threading `ParameterTable` into every glyph-render call site // threading `ParameterTable` into every glyph-render call site
// (`src/ui/overlay.rs`) — showing the name itself is enough for now; // (`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 // once a named `driving_param` can actually be authored through the
// UI (nothing can yet — this arm exists so the match is exhaustive // UI (nothing can yet — this arm exists so the match is exhaustive
// and correct ahead of that UI, not because it's reachable today). // 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(), (_, 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 /// 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 /// circle's rightmost point) since there's no single named point to anchor
/// on otherwise. /// on otherwise.
pub(crate) fn glyph_anchor(document: &acadrust::CadDocument, constraint: &SketchConstraint) -> Option<Vector3> { pub(crate) fn glyph_anchor(
document: &acadrust::CadDocument,
constraint: &SketchConstraint,
) -> Option<Vector3> {
let r = constraint.refs.first()?; let r = constraint.refs.first()?;
let entity = document.get_entity(r.entity)?; let entity = document.get_entity(r.entity)?;
if let Some(marker) = r.marker { if let Some(marker) = r.marker {
return resolve_point(entity, marker); return resolve_point(entity, marker);
} }
match entity { match entity {
acadrust::EntityType::Line(l) => { acadrust::EntityType::Line(l) => Some(Vector3::new(
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)) (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)), acadrust::EntityType::Circle(c) => Some(c.point_at_angle_wcs(0.0)),
_ => None, _ => None,
} }
@@ -484,16 +484,20 @@ impl super::Scene {
/// scope — this is the one way to reach a scope's set for both reading /// scope — this is the one way to reach a scope's set for both reading
/// and mutating. /// and mutating.
pub fn sketch_constraint_set_mut(&mut self, scope: SketchScope) -> &mut SketchConstraintSet { 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] &mut self.sketch_constraints[index]
} else { } else {
self.sketch_constraints.push(SketchConstraintSet::new(scope)); self.sketch_constraints
.push(SketchConstraintSet::new(scope));
self.sketch_constraints.last_mut().expect("just pushed") self.sketch_constraints.last_mut().expect("just pushed")
} }
} }
/// Design doc §5.3/§12 (open question 3, now resolved): copy/paste's /// Handle remapping lives in each command that duplicates
/// handle remapping lives locally in each command that duplicates
/// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR, /// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR,
/// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own /// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own
/// (clipboard paste, `src/app/command_driver.rs`) — rather than in one /// (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 /// 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 /// same way any other edit would. A no-op when `handle_map` is empty or
/// nothing constrained was duplicated. /// nothing constrained was duplicated.
pub fn duplicate_sketch_constraints_for(&mut self, handle_map: &rustc_hash::FxHashMap<Handle, Handle>) { pub fn duplicate_sketch_constraints_for(
&mut self,
handle_map: &rustc_hash::FxHashMap<Handle, Handle>,
) {
if handle_map.is_empty() { if handle_map.is_empty() {
return; return;
} }
let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<DrivingValue>)> = Vec::new(); let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<DrivingValue>)> =
Vec::new();
for (scope_index, set) in self.sketch_constraints.iter().enumerate() { for (scope_index, set) in self.sketch_constraints.iter().enumerate() {
for c in &set.constraints { for c in &set.constraints {
if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) {
continue; continue;
} }
let new_refs: Vec<SketchRef> = let new_refs: Vec<SketchRef> = c
c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect(); .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())); to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone()));
} }
} }
@@ -532,7 +546,10 @@ impl super::Scene {
} }
touched.sort(); touched.sort();
touched.dedup(); 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); self.bump_entities(&changes);
} }
@@ -547,14 +564,21 @@ impl super::Scene {
let mut out = Vec::new(); let mut out = Vec::new();
for set in &self.sketch_constraints { for set in &self.sketch_constraints {
for c in &set.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 { if n != name {
continue; continue;
} }
let mut entities: Vec<Handle> = c.refs.iter().map(|r| r.entity).collect(); let mut entities: Vec<Handle> = c.refs.iter().map(|r| r.entity).collect();
entities.sort(); entities.sort();
entities.dedup(); 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 out
@@ -582,20 +606,38 @@ mod tests {
#[test] #[test]
fn add_assigns_increasing_ids_and_get_finds_them() { fn add_assigns_increasing_ids_and_get_finds_them() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); let a = set.add(
let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(DrivingValue::Literal(25.0))); 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_ne!(a, b);
assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); 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] #[test]
fn remove_drops_only_the_matching_id() { fn remove_drops_only_the_matching_id() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); 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); let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None);
assert!(set.remove(a)); 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(a).is_none());
assert!(set.get(b).is_some()); assert!(set.get(b).is_some());
} }
@@ -603,8 +645,16 @@ mod tests {
#[test] #[test]
fn constraints_touching_finds_entity_regardless_of_marker() { fn constraints_touching_finds_entity_regardless_of_marker() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); 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(
set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None); 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(); let touching_1: Vec<_> = set.constraints_touching(h(1)).collect();
assert_eq!(touching_1.len(), 1); assert_eq!(touching_1.len(), 1);
@@ -616,21 +666,40 @@ mod tests {
#[test] #[test]
fn constraints_touching_skips_disabled() { fn constraints_touching_skips_disabled() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let id = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); let id = set.add(
set.constraints.iter_mut().find(|c| c.id == id).unwrap().enabled = false; 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); assert_eq!(set.constraints_touching(h(1)).count(), 0);
} }
#[test] #[test]
fn remove_all_touching_drops_every_constraint_referencing_the_entity() { fn remove_all_touching_drops_every_constraint_referencing_the_entity() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); 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 coincident = set.add(
let horizontal_other = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None); 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)); let removed = set.remove_all_touching(h(1));
assert_eq!(removed, vec![coincident]); assert_eq!(removed, vec![coincident]);
assert!(set.get(coincident).is_none()); 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] #[test]
@@ -644,54 +713,87 @@ mod tests {
#[test] #[test]
fn ref_center_constructor_matches_the_dash_three_convention() { fn ref_center_constructor_matches_the_dash_three_convention() {
let r = SketchRef::center(h(7)); 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] #[test]
fn sketch_constraint_set_round_trips_through_bincode() { fn sketch_constraint_set_round_trips_through_bincode() {
let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); 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(
set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(DrivingValue::Literal(12.5))); 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 bytes = bincode::serialize(&set).expect("serialize");
let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize"); let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(restored.scope, set.scope); assert_eq!(restored.scope, set.scope);
assert_eq!(restored.constraints.len(), set.constraints.len()); 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); assert_eq!(restored.constraints[0].refs, set.constraints[0].refs);
} }
#[test] #[test]
fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() {
let mut scene = super::super::Scene::new(); let mut scene = super::super::Scene::new();
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( scene
ConstraintKind::Distance, .sketch_constraint_set_mut(SketchScope::ModelSpace)
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], .add(
Some(DrivingValue::Named("gap".to_string())), ConstraintKind::Distance,
); vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( Some(DrivingValue::Named("gap".to_string())),
ConstraintKind::Radius, );
vec![SketchRef::whole(h(2))], scene
Some(DrivingValue::Named("gap".to_string())), .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 // Unrelated: a literal-driven constraint and one driven by a
// different name must not show up. // different name must not show up.
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( scene
ConstraintKind::Radius, .sketch_constraint_set_mut(SketchScope::ModelSpace)
vec![SketchRef::whole(h(3))], .add(
Some(DrivingValue::Literal(5.0)), ConstraintKind::Radius,
); vec![SketchRef::whole(h(3))],
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( Some(DrivingValue::Literal(5.0)),
ConstraintKind::Distance, );
vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], scene
Some(DrivingValue::Named("other".to_string())), .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"); let usage = scene.parameter_usage("gap");
assert_eq!(usage.len(), 2, "exactly the two constraints driven by 'gap', got {usage:?}"); assert_eq!(
assert!(usage.iter().any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); usage.len(),
assert!(usage.iter().any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); 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); assert_eq!(scene.parameter_usage("nonexistent").len(), 0);
} }
@@ -700,11 +802,13 @@ mod tests {
fn parameter_usage_searches_every_scope_not_just_model_space() { fn parameter_usage_searches_every_scope_not_just_model_space() {
let mut scene = super::super::Scene::new(); let mut scene = super::super::Scene::new();
let block = h(99); let block = h(99);
scene.sketch_constraint_set_mut(SketchScope::Block(block)).add( scene
ConstraintKind::Radius, .sketch_constraint_set_mut(SketchScope::Block(block))
vec![SketchRef::whole(h(1))], .add(
Some(DrivingValue::Named("r".to_string())), ConstraintKind::Radius,
); vec![SketchRef::whole(h(1))],
Some(DrivingValue::Named("r".to_string())),
);
let usage = scene.parameter_usage("r"); let usage = scene.parameter_usage("r");
assert_eq!(usage.len(), 1); assert_eq!(usage.len(), 1);
assert_eq!(usage[0].scope, SketchScope::Block(block)); 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 // 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 // entity (e.g. a line's own start and end) must list that entity
// once, not twice. // once, not twice.
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( scene
ConstraintKind::Distance, .sketch_constraint_set_mut(SketchScope::ModelSpace)
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], .add(
Some(DrivingValue::Named("len".to_string())), 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"); let usage = scene.parameter_usage("len");
assert_eq!(usage.len(), 1); assert_eq!(usage.len(), 1);
assert_eq!(usage[0].entities, vec![h(1)]); assert_eq!(usage[0].entities, vec![h(1)]);
+115 -68
View File
@@ -1,23 +1,9 @@
//! Save/load integration for [`super::sketch_constraints::SketchConstraintSet`] //! Persists each sketch constraint set in a versioned, chunked XRecord.
//! — 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.
use super::sketch_constraints::SketchConstraintSet; use super::sketch_constraints::SketchConstraintSet;
use super::Scene; use super::Scene;
use acadrust::{CadDocument, Handle}; use acadrust::{CadDocument, Handle};
use bincode::Options;
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS"; 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 /// around it without touching the vendored crate — `CadDocument::xrecord`'s
/// `entries: Vec<XRecordEntry>` already supports repeating a code. /// `entries: Vec<XRecordEntry>` already supports repeating a code.
const MAX_CHUNK_BYTES: usize = u8::MAX as usize; 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. /// 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 /// 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 { else {
return; 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; return;
}; };
let (_, record_handle) = dictionary.entries.remove(index); let (_, record_handle) = dictionary.entries.remove(index);
document.objects.remove(&record_handle); document.objects.remove(&record_handle);
} }
/// Prefixed onto every serialized blob so a future schema change can be /// Serialized schema version. Unknown versions are skipped without affecting
/// detected and gracefully skipped (dropping just that one scope's /// the rest of the drawing.
/// 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<f64>` to
/// `Option<named_parameters::DrivingValue>` (an enum, with its own
/// discriminant), so a version-1 blob's bytes no longer line up with the
/// current struct shape — exactly the "detected and gracefully skipped"
/// case this version byte exists for, not something to migrate (no
/// migration path exists yet, per the call-out above).
const FORMAT_VERSION: u8 = 2; const FORMAT_VERSION: u8 = 2;
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> { fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION]; 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) Some(bytes)
} }
@@ -84,7 +67,12 @@ fn decode(bytes: &[u8]) -> Option<SketchConstraintSet> {
if version != FORMAT_VERSION { if version != FORMAT_VERSION {
return None; 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 { impl Scene {
@@ -145,16 +133,26 @@ impl Scene {
/// blank document has nothing to find anyway). /// blank document has nothing to find anyway).
pub(crate) fn load_sketch_constraints_from_document(&mut self) { pub(crate) fn load_sketch_constraints_from_document(&mut self) {
self.sketch_constraints.clear(); self.sketch_constraints.clear();
let owners: Vec<acadrust::Handle> = let owners: Vec<acadrust::Handle> = self
self.document.block_records.iter().map(|record| record.handle).collect(); .document
.block_records
.iter()
.map(|record| record.handle)
.collect();
for owner in owners { 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 — // Concatenate every Chunk entry in order, not just the first —
// a set materialized past `MAX_CHUNK_BYTES` spans more than one // a set materialized past `MAX_CHUNK_BYTES` spans more than one
// same-code `310` entry (see that constant's doc comment). // same-code `310` entry (see that constant's doc comment).
let mut bytes = Vec::new(); let mut bytes = Vec::new();
for entry in &record.entries { for entry in &record.entries {
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { 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); bytes.extend_from_slice(chunk);
} }
} }
@@ -182,11 +180,9 @@ mod tests {
Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0),
))); )));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( scene
ConstraintKind::Horizontal, .sketch_constraint_set_mut(SketchScope::ModelSpace)
vec![SketchRef::whole(a)], .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
None,
);
scene.materialize_sketch_constraints_for_save(); scene.materialize_sketch_constraints_for_save();
// Simulate the load-time side of a save/reload round trip: drop the // Simulate the load-time side of a save/reload round trip: drop the
@@ -195,7 +191,9 @@ mod tests {
scene.sketch_constraints.clear(); scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document(); 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.len(), 1);
assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal);
assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]); 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(10.0, 5.0, 0.0),
Vector3::new(20.0, 5.0, 0.0), Vector3::new(20.0, 5.0, 0.0),
))); )));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( scene
ConstraintKind::Coincident, .sketch_constraint_set_mut(SketchScope::ModelSpace)
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], .add(
None, ConstraintKind::Coincident,
); vec![SketchRef::point(a, 1), SketchRef::point(b, 0)],
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( None,
ConstraintKind::Distance, );
vec![SketchRef::whole(b)], scene
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), .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(); scene.materialize_sketch_constraints_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) 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}")); .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
reloaded_scene.load_sketch_constraints_from_document(); reloaded_scene.load_sketch_constraints_from_document();
reloaded_scene.sketch_constraint_set(SketchScope::ModelSpace).cloned().unwrap_or_else(|| { reloaded_scene
panic!("no ModelSpace constraint set survived the {ext} save/load round trip") .sketch_constraint_set(SketchScope::ModelSpace)
}) .cloned()
.unwrap_or_else(|| {
panic!("no ModelSpace constraint set survived the {ext} save/load round trip")
})
} }
#[test] #[test]
@@ -248,7 +253,10 @@ mod tests {
assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); 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] #[test]
@@ -257,7 +265,10 @@ mod tests {
assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); 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] #[test]
@@ -274,10 +285,22 @@ mod tests {
// handle"), so look it up from the table by name instead of using // handle"), so look it up from the table by name instead of using
// the return value directly. // the return value directly.
let _insert_handle = scene 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"); .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; let br_handle = scene
scene.sketch_constraint_set_mut(SketchScope::Block(br_handle)).add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); .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.materialize_sketch_constraints_for_save();
scene.sketch_constraints.clear(); scene.sketch_constraints.clear();
@@ -300,7 +323,10 @@ mod tests {
scene.materialize_sketch_constraints_for_save(); scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle; 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] #[test]
@@ -316,29 +342,50 @@ mod tests {
Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.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(); scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle; 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 // Clear every constraint (mirrors deleting the constrained entity, or
// removing the last constraint by hand) and save again. // 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(); 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. // And the load path must agree: nothing resurrects on reload.
scene.sketch_constraints.clear(); scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document(); 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] #[test]
fn decode_rejects_a_mismatched_format_version() { fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap()); bytes.extend(
assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread"); bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap(),
);
assert!(
decode(&bytes).is_none(),
"a future/unknown format version must be rejected, not misread"
);
} }
} }
+714 -317
View File
File diff suppressed because it is too large. Load diff
+17 -89
View File
@@ -153,9 +153,8 @@ pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
} }
let built = sysfont::with_face_data(family, |data, index| { 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)?; let gid = face.glyph_index(ch)?;
pin_default_variations(&mut face);
let k = cap_scale(&face); let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
@@ -339,12 +338,11 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let script = crate::scene::text::web_font::script_of(ch)?; let script = crate::scene::text::web_font::script_of(ch)?;
let bytes = crate::scene::text::web_font::request(script)?; let bytes = crate::scene::text::web_font::request(script)?;
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1); 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 face = ttf_parser::Face::parse(&bytes, face_index).ok()?;
let gid = face.glyph_index(ch)?; let gid = face.glyph_index(ch)?;
Some((face, gid)) Some((face, gid))
})?; })?;
pin_default_variations(&mut face);
let k = cap_scale(&face); let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k); let mut fl = OutlineFlattener::new(k);
@@ -361,46 +359,6 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
})) }))
} }
/// 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<Arc<Glyph>> {
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"))] #[cfg(not(target_arch = "wasm32"))]
fn build_fallback(ch: char) -> Option<Arc<Glyph>> { fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; use cosmic_text::{Attrs, Buffer, Metrics, Shaping};
@@ -413,54 +371,26 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None); buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false); buf.shape_until_scroll(&mut fs, false);
let mut tried: Vec<fontdb::ID> = Vec::new();
for run in buf.layout_runs() { for run in buf.layout_runs() {
for g in run.glyphs.iter() { for g in run.glyphs.iter() {
if g.glyph_id == 0 { if g.glyph_id == 0 {
continue; // .notdef — this font doesn't really cover it 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 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 font = fs.get_font(g.font_id, g.font_weight)?;
let Ok(mut face) = ttf_parser::Face::parse(font.data(), face_index) else { continue }; let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
pin_default_variations(&mut face);
let k = cap_scale(&face); let k = cap_scale(&face);
if let Some(glyph) = outline_from_face(&face, ttf_parser::GlyphId(g.glyph_id), k) { let gid = ttf_parser::GlyphId(g.glyph_id);
return Some(glyph); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
} let mut fl = OutlineFlattener::new(k);
// cosmic-text resolved a real glyph here, but this font's outline face.outline_glyph(gid, &mut fl);
// couldn't actually be extracted — confirmed (design doc-worthy, fl.flush();
// not a guess) on this dev machine for macOS's system font let fill_tris = triangulate_contours(&fl.contours);
// (San Francisco): it's a variable TrueType font whose `gvar` return Some(Arc::new(Glyph {
// outline path needs base `glyf` glyph data `ttf-parser` can't strokes: fl.contours,
// retrieve for at least some of its accented Latin glyphs, even advance,
// after every variation axis is pinned to its default. Rather fill_tris,
// than silently rendering nothing, fall through to a direct }));
// database scan below for any other installed font that also
// covers `ch` and can actually be outlined.
}
}
// Second-chance search: cosmic-text's own fallback chose one font (and
// it failed to outline above); look for a different installed font that
// also covers `ch`, skipping whatever was already tried. Bounded by the
// database size, not attempted per keystroke — `fallback_glyph`'s own
// cache means this only runs once per distinct character ever asked
// for.
let candidates: Vec<(fontdb::ID, u32)> = fs
.db()
.faces()
.filter(|f| !tried.contains(&f.id))
.map(|f| (f.id, f.index))
.collect();
for (id, index) in candidates {
let Some(font) = fs.get_font(id, cosmic_text::fontdb::Weight::NORMAL) else { continue };
let Ok(mut face) = ttf_parser::Face::parse(font.data(), index) else { continue };
let Some(gid) = face.glyph_index(ch) else { continue };
pin_default_variations(&mut face);
let k = cap_scale(&face);
if let Some(glyph) = outline_from_face(&face, gid, k) {
return Some(glyph);
} }
} }
None None
@@ -526,10 +456,9 @@ fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else { let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else {
continue; 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; continue;
}; };
pin_default_variations(&mut face);
let scale = (SHAPE_FS / face.units_per_em() as f32) * px_to_9; 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_x = (glyph.x + SHAPE_FS * glyph.x_offset) * px_to_9;
let pen_y = -(SHAPE_FS * glyph.y_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<ShapedRun> {
let Some(font) = fs.get_font(g.font_id, g.font_weight) else { let Some(font) = fs.get_font(g.font_id, g.font_weight) else {
continue; 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; continue;
}; };
pin_default_variations(&mut face);
let upem_g = face.units_per_em() as f32; let upem_g = face.units_per_em() as f32;
// Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units. // Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units.
let scale_g = (SHAPE_FS / upem_g) * px_to_9; let scale_g = (SHAPE_FS / upem_g) * px_to_9;
+4 -12
View File
@@ -728,12 +728,7 @@ struct SelectionCanvas {
crosshair_bg: [f32; 4], crosshair_bg: [f32; 4],
crosshair: CrosshairOptions, crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions, selection_visual: SelectionVisualOptions,
/// One glyph per persistent sketch constraint in the current scope /// Constraint glyph anchor, label, and conflict state for 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_glyphs: Vec<(Point, String, bool)>, constraint_glyphs: Vec<(Point, String, bool)>,
} }
@@ -1630,18 +1625,15 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&b1, stroke.clone()); frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke); frame.stroke(&b2, stroke);
} }
// Persistent sketch-constraint glyphs (design doc §6.3/§7): a small // Constraint glyphs are visual-only and have no hit testing.
// pill at each constraint's anchor point, its symbol (plus value for
// a dimensional kind) as text — visual-only for now, no hit-testing.
if !self.constraint_glyphs.is_empty() { if !self.constraint_glyphs.is_empty() {
const GLYPH_SIZE: f32 = 11.0; const GLYPH_SIZE: f32 = 11.0;
const GLYPH_PAD_X: f32 = 5.0; const GLYPH_PAD_X: f32 = 5.0;
const GLYPH_PAD_Y: f32 = 2.0; const GLYPH_PAD_Y: f32 = 2.0;
let normal_bg = theme.palette().primary.base.color; let normal_bg = theme.palette().primary.base.color;
let normal_fg = theme.palette().primary.base.text; let normal_fg = theme.palette().primary.base.text;
// Design doc §6.3's recommended color cue: a constraint // A redundant or conflicting constraint gets the danger palette
// `solve_scope` flagged as redundant/conflicting (stage 10/11's // instead of the
// `classify_redundant`) gets the danger palette instead of the
// ordinary primary one — same information a resolver panel // ordinary primary one — same information a resolver panel
// would show, surfaced right on the geometry. // would show, surfaced right on the geometry.
let conflict_bg = theme.palette().danger.base.color; let conflict_bg = theme.palette().danger.base.color;
+1 -2
View File
@@ -1397,8 +1397,7 @@ impl PropertiesPanel {
} }
/// One named-parameter row: editable name + formula, with the live- /// One named-parameter row: editable name + formula, with the live-
/// resolved value (or error) shown alongside — the "AutoCAD style" the /// resolved value (or error) shown alongside, embedded in the panel instead of
/// Parameters section is modeled on, embedded in the panel instead of
/// the old separate modal. Each field commits on submit (Enter / losing /// the old separate modal. Each field commits on submit (Enter / losing
/// focus), not per keystroke — `ParameterTable::set` validates /// focus), not per keystroke — `ParameterTable::set` validates
/// immediately and would otherwise reject a formula mid-type (the same /// immediately and would otherwise reject a formula mid-type (the same
+3 -3
View File
@@ -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<String>, font_size: f32) -> Element<'a, Message> { pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Element<'a, Message> {
if title != "Draw" || TOOLS.is_empty() { 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]) .padding([1, 4])
.into(); .into();
} }
@@ -148,7 +148,7 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>, font_s
PosReport::new( PosReport::new(
PANEL_ID, PANEL_ID,
button( button(
row![text(t!(title)).size(font_size), arrow] row![text(t!(title)).size(9), arrow]
.spacing(4) .spacing(4)
.align_y(iced::Center), .align_y(iced::Center),
) )
+6 -51
View File
@@ -96,12 +96,6 @@ pub struct Ribbon {
tool_bar_h: Arc<AtomicU32>, tool_bar_h: Arc<AtomicU32>,
/// User-chosen panel density (persisted). `Auto` sizes by window width. /// User-chosen panel density (persisted). `Auto` sizes by window width.
collapse_mode: CollapseMode, 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 /// 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. /// selector hides itself then to give the cramped tab row its space back.
collapse_tight: Arc<AtomicBool>, collapse_tight: Arc<AtomicBool>,
@@ -198,8 +192,6 @@ impl Ribbon {
tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())), tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())),
collapse_mode: CollapseMode::default(), collapse_mode: CollapseMode::default(),
collapse_tight: Arc::new(AtomicBool::new(false)), 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 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. /// Current tab-bar height as last measured by the `WrapBar` widget.
fn tab_bar_height(&self) -> f32 { fn tab_bar_height(&self) -> f32 {
f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed)) f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed))
@@ -633,8 +603,6 @@ impl Ribbon {
&self.active_linetype, &self.active_linetype,
self.active_lineweight, self.active_lineweight,
&style_ctx, &style_ctx,
self.label_font_size,
self.group_title_font_size,
), ),
render_group( render_group(
true, true,
@@ -649,8 +617,6 @@ impl Ribbon {
&self.active_linetype, &self.active_linetype,
self.active_lineweight, self.active_lineweight,
&style_ctx, &style_ctx,
self.label_font_size,
self.group_title_font_size,
), ),
collapse_button( collapse_button(
g, g,
@@ -665,7 +631,6 @@ impl Ribbon {
&self.active_linetype, &self.active_linetype,
self.active_lineweight, self.active_lineweight,
&style_ctx, &style_ctx,
self.label_font_size,
false, false,
), ),
collapse_button( collapse_button(
@@ -681,14 +646,13 @@ impl Ribbon {
&self.active_linetype, &self.active_linetype,
self.active_lineweight, self.active_lineweight,
&style_ctx, &style_ctx,
self.label_font_size,
true, true,
), ),
], ],
} }
}) })
.collect(); .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_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone()) .report_tight(self.collapse_tight.clone())
.mode(self.collapse_mode) .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. /// When `compact`, large tools/dropdowns are drawn as small icon columns.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_group<'a>( fn render_group<'a>(
@@ -1355,8 +1319,6 @@ fn render_group<'a>(
active_linetype: &'a str, active_linetype: &'a str,
active_lineweight: LineWeight, active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>, style_ctx: &StyleContext<'_>,
label_font_size: i32,
group_title_font_size: i32,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = Vec::new(); let mut items_row: Vec<Element<Message>> = Vec::new();
let mut small_buf: Vec<Element<Message>> = Vec::new(); let mut small_buf: Vec<Element<Message>> = Vec::new();
@@ -1373,7 +1335,6 @@ fn render_group<'a>(
active_lineweight, active_lineweight,
style_ctx, style_ctx,
compact, compact,
label_font_size: label_font_size as f32,
}; };
for item in &group.tools { for item in &group.tools {
@@ -1411,12 +1372,12 @@ fn render_group<'a>(
draw_panel::group_anchor(group.title, column![ draw_panel::group_anchor(group.title, column![
tools_el, 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) .align_x(iced::Center)
.spacing(0) .spacing(0)
.padding([3u16, 4]) .padding([3u16, 4])
.height(Length::Fixed(widgets::tool_bar_h(group_title_font_size as f32))) .height(Length::Fixed(TOOL_BAR_H))
.into()) .into())
} }
@@ -1479,7 +1440,6 @@ fn collapse_button<'a>(
active_linetype: &'a str, active_linetype: &'a str,
active_lineweight: LineWeight, active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>, style_ctx: &StyleContext<'_>,
label_font_size: i32,
// When set, this is the TIGHT step (reached once even the all-collapsed row // 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 // 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. // tool's icon + a ▾ that opens the tools flyout. Nothing runs directly here.
@@ -1520,9 +1480,7 @@ fn collapse_button<'a>(
.style(button::subtle) .style(button::subtle)
.width(Fill) .width(Fill)
.padding([3, 5]); .padding([3, 5]);
// This tight-mode panel *title* (not an icon caption) keeps its own return automatic_large_button(localized_title, content.into());
// fixed size regardless of the icon-label font-size setting.
return automatic_large_button(localized_title, content.into(), 9.0);
} }
// Collapsed (not yet tight): a large representative-tool face — a live button // Collapsed (not yet tight): a large representative-tool face — a live button
@@ -1545,7 +1503,6 @@ fn collapse_button<'a>(
active_lineweight, active_lineweight,
style_ctx, style_ctx,
compact: false, compact: false,
label_font_size: label_font_size as f32,
}, },
) )
} }
@@ -1564,7 +1521,6 @@ fn collapse_button<'a>(
active_lineweight, active_lineweight,
style_ctx, style_ctx,
compact: false, compact: false,
label_font_size: label_font_size as f32,
}, },
) )
} }
@@ -1589,8 +1545,7 @@ fn collapse_button<'a>(
.style(button::subtle) .style(button::subtle)
.width(Fill) .width(Fill)
.padding([1, 4]); .padding([1, 4]);
// Same fixed-size opener title as the tight-mode branch above. let opener = automatic_large_button(localized_title, opener.into());
let opener = automatic_large_button(localized_title, opener.into(), 9.0);
// The large face fills a fixed slot so a collapsed panel is shorter than a full // 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. // 3-row panel, letting `CollapsePanels` shrink the ribbon row.
+28 -92
View File
@@ -56,40 +56,15 @@ pub(super) const LARGE_W: f32 = ROW_H * 2.2;
const LARGE_LABEL_HPAD: f32 = 8.0; const LARGE_LABEL_HPAD: f32 = 8.0;
/// Large ribbon labels use at most two lines before their button grows. /// Large ribbon labels use at most two lines before their button grows.
const LARGE_LABEL_LINES: f32 = 2.0; const LARGE_LABEL_LINES: f32 = 2.0;
const LARGE_LABEL_SIZE: f32 = 10.0;
/// Width of a 1-row (small) button. /// Width of a 1-row (small) button.
pub(super) const SMALL_W: f32 = ROW_H; pub(super) const SMALL_W: f32 = ROW_H;
/// Width of the ▾ strip on a small dropdown. /// Width of the ▾ strip on a small dropdown.
pub(super) const ARROW_W: f32 = ROW_H * 0.4; pub(super) const ARROW_W: f32 = ROW_H * 0.4;
/// Height of the ▾ strip at the bottom of a large dropdown. /// Height of the ▾ strip at the bottom of a large dropdown.
pub(super) const LARGE_ARR: f32 = ROW_H * 0.55; 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, /// 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.
pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0; 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 + /// 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 /// 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. /// 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! { thread_local! {
/// Ribbon layout runs on every pointer-driven view update. Cache the font- /// Ribbon layout runs on every pointer-driven view update. Cache the font-
/// measured width per (translated label, font size) so the automatic /// measured width per translated label so the automatic sizing stays cheap.
/// sizing stays cheap — keyed on both since the same label text is static LARGE_WIDTH_CACHE: RefCell<HashMap<String, f32>> =
/// 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<HashMap<(String, u32), f32>> =
RefCell::new(HashMap::default()); RefCell::new(HashMap::default());
} }
@@ -124,7 +96,6 @@ fn ribbon_label_bounds(
label: &str, label: &str,
width: f32, width: f32,
wrapping: advanced_text::Wrapping, wrapping: advanced_text::Wrapping,
label_size: f32,
) -> Size { ) -> Size {
use advanced_text::{Paragraph as _, Renderer as _}; use advanced_text::{Paragraph as _, Renderer as _};
@@ -132,7 +103,7 @@ fn ribbon_label_bounds(
advanced_text::Text { advanced_text::Text {
content: label, content: label,
bounds: Size::new(width, f32::INFINITY), bounds: Size::new(width, f32::INFINITY),
size: Pixels(label_size), size: Pixels(LARGE_LABEL_SIZE),
line_height: advanced_text::LineHeight::default(), line_height: advanced_text::LineHeight::default(),
font: renderer.default_font(), font: renderer.default_font(),
align_x: advanced_text::Alignment::Center, align_x: advanced_text::Alignment::Center,
@@ -146,41 +117,27 @@ fn ribbon_label_bounds(
paragraph.min_bounds() paragraph.min_bounds()
} }
/// Widest a large button will grow to keep its label on one line before /// Measure the translated label at the normal button width. It wraps first;
/// falling back to two-line wrapping instead. A label pre-broken with an /// only labels that would need more than two lines widen their button. The
/// explicit `\n` (long labels get hand-wrapped at the source, e.g. "Viewport /// binary search uses the renderer's real font metrics, so locale and UI scale
/// 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
/// changes do not rely on character-count estimates. /// 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 base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0);
let line_height = advanced_text::LineHeight::default() let line_height = advanced_text::LineHeight::default()
.to_absolute(Pixels(label_size)) .to_absolute(Pixels(LARGE_LABEL_SIZE))
.0; .0;
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
// `Wrapping::Word` (not `WordOrGlyph`): a label that doesn't fit at this let fits = |width: f32| {
// 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 bounds = ribbon_label_bounds( let bounds = ribbon_label_bounds(
renderer, renderer,
label, label,
width, width,
advanced_text::Wrapping::Word, 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; return LARGE_W;
} }
@@ -189,33 +146,18 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32)
label, label,
f32::INFINITY, f32::INFINITY,
advanced_text::Wrapping::None, advanced_text::Wrapping::None,
label_size,
) )
.width .width
.max(base_inner); .max(base_inner);
if natural <= LARGE_LABEL_MAX_SINGLE_LINE_W { if !fits(natural) {
return (natural + LARGE_LABEL_HPAD).ceil(); 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 low = base_inner;
let mut high = LARGE_LABEL_MAX_SINGLE_LINE_W; let mut high = natural;
for _ in 0..10 { for _ in 0..10 {
let mid = (low + high) * 0.5; let mid = (low + high) * 0.5;
if fits_two_lines(mid) { if fits(mid) {
high = mid; high = mid;
} else { } else {
low = mid; 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) (high + LARGE_LABEL_HPAD).ceil().max(LARGE_W)
} }
fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 { fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
let key = (label.to_string(), label_size.to_bits()); if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) {
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(&key).copied()) {
return width; return width;
} }
let width = measure_large_width(renderer, label, label_size); let width = measure_large_width(renderer, label);
LARGE_WIDTH_CACHE.with(|cache| { LARGE_WIDTH_CACHE.with(|cache| {
cache.borrow_mut().insert(key, width); cache.borrow_mut().insert(label.to_string(), width);
}); });
width width
} }
@@ -240,7 +181,6 @@ fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32
struct AutomaticLargeWidth<'a> { struct AutomaticLargeWidth<'a> {
label: String, label: String,
content: Element<'a, Message>, content: Element<'a, Message>,
label_size: f32,
} }
impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> { impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
@@ -266,7 +206,7 @@ impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
renderer: &iced::Renderer, renderer: &iced::Renderer,
limits: &layout::Limits, limits: &layout::Limits,
) -> layout::Node { ) -> 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( self.content.as_widget_mut().layout(
tree, tree,
renderer, renderer,
@@ -346,9 +286,8 @@ impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
pub(super) fn automatic_large_button<'a>( pub(super) fn automatic_large_button<'a>(
label: String, label: String,
content: Element<'a, Message>, content: Element<'a, Message>,
label_size: f32,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
Element::new(AutomaticLargeWidth { label, content, label_size }) Element::new(AutomaticLargeWidth { label, content })
} }
// ── Tab-bar constants ────────────────────────────────────────────────────── // ── Tab-bar constants ──────────────────────────────────────────────────────
@@ -693,9 +632,6 @@ pub(super) struct RenderCtx<'a> {
pub style_ctx: &'a StyleContext<'a>, pub style_ctx: &'a StyleContext<'a>,
/// When compact, the Properties panel's Match button shrinks to a small icon. /// When compact, the Properties panel's Match button shrinks to a small icon.
pub compact: bool, 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 /// 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_x(iced::Center)
.align_y(iced::Center), .align_y(iced::Center),
text(label.clone()) text(label.clone())
.size(ctx.label_font_size) .size(10)
.width(Fill) .width(Fill)
.align_x(iced::Center) .align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word), .wrapping(advanced_text::Wrapping::Word),
@@ -798,7 +734,7 @@ pub(super) fn render_large_dropdown<'a>(
.width(Fill) .width(Fill)
.height(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() .into()
} }
@@ -878,7 +814,7 @@ pub(super) fn render_large<'a>(
.align_x(iced::Center) .align_x(iced::Center)
.align_y(iced::Center), .align_y(iced::Center),
text(label.clone()) text(label.clone())
.size(ctx.label_font_size) .size(10)
.width(Fill) .width(Fill)
.align_x(iced::Center) .align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word), .wrapping(advanced_text::Wrapping::Word),
@@ -899,7 +835,7 @@ pub(super) fn render_large<'a>(
left: 4.0, left: 4.0,
}); });
tooltip( tooltip(
automatic_large_button(label, btn.into(), ctx.label_font_size), automatic_large_button(label, btn.into()),
make_tip(tip_text), make_tip(tip_text),
TipPos::Right, TipPos::Right,
) )
+6 -13
View File
@@ -125,13 +125,13 @@ impl StatusBar {
// Which pills the user has chosen to show on the bar. // Which pills the user has chosen to show on the bar.
config: &'a StatusBarConfig, config: &'a StatusBarConfig,
menu_data: StatusMenuData<'a>, menu_data: StatusMenuData<'a>,
// Remaining degrees of freedom for the current sketch scope's // Remaining degrees of freedom for the current sketch scope.
// persistent constraints (design doc §6.3) — `None` when the scope // `None` when the scope
// has no `SketchConstraintSet` yet (nothing constrained), so the // has no `SketchConstraintSet` yet (nothing constrained), so the
// badge stays invisible until it's actually relevant. // badge stays invisible until it's actually relevant.
sketch_dof: Option<usize>, sketch_dof: Option<usize>,
// Number of redundant/conflicting constraints `solve_scope` flagged // Number of redundant/conflicting constraints in the current scope.
// in the current sketch scope (design doc §6.4, stage 11) — 0 hides // Zero hides
// the pill entirely, so an ordinarily/fully-constrained drawing sees // the pill entirely, so an ordinarily/fully-constrained drawing sees
// no new clutter. // no new clutter.
sketch_conflicts: usize, sketch_conflicts: usize,
@@ -274,11 +274,7 @@ impl StatusBar {
.into(), .into(),
); );
} }
// Design doc §6.4 (stage 11): a bounded v1 of the guided conflict // Each click removes one flagged constraint.
// 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.
if sketch_conflicts > 0 { if sketch_conflicts > 0 {
pills.push( pills.push(
tip( tip(
@@ -1082,10 +1078,7 @@ fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
.into() .into()
} }
/// Like [`status_pill`], but with the theme's `success` accent — design doc /// A success-colored status pill used when no degrees of freedom remain.
/// §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.
fn success_pill(label: impl Into<String>) -> Element<'static, Message> { fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
container(text(label.into()).size(12)) container(text(label.into()).size(12))
.style(|theme: &Theme| { .style(|theme: &Theme| {
+104 -39
View File
@@ -1,6 +1,4 @@
//! Named-parameter table editor — an in-canvas modal (Plan B), mirroring //! Named-parameter table editor using buffered `(name, formula)` rows.
//! `alias_editor.rs`'s buffered-rows pattern exactly. Opened by PARAMETERS
//! (`docs/named_parameters_design.md` stage 4). Rows are `(name, formula)`,
//! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`) //! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`)
//! and committed to `Scene::named_parameters` only on Apply — matching the //! and committed to `Scene::named_parameters` only on Apply — matching the
//! alias editor's "closing discards unapplied edits" convention, which //! 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::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space};
use iced::{Background, Element, Length, Theme}; use iced::{Background, Element, Length, Theme};
/// Short "Kind handle" label for one referenced entity — the same handle /// Short "Kind handle" label for an entity without a user-facing name.
/// display (`0x…`) AutoCAD/this app's own conventions use, since entities
/// have no user-facing name to show instead.
fn entity_label(scene: &Scene, handle: Handle) -> String { fn entity_label(scene: &Scene, handle: Handle) -> String {
match scene.document.get_entity(handle) { match scene.document.get_entity(handle) {
Some(EntityType::Line(_)) => format!("Line {handle}"), Some(EntityType::Line(_)) => format!("Line {handle}"),
@@ -46,7 +42,12 @@ fn usage_lines(scene: &Scene, name: &str) -> Vec<String> {
.parameter_usage(name) .parameter_usage(name)
.iter() .iter()
.map(|u| { .map(|u| {
let entities = u.entities.iter().map(|h| entity_label(scene, *h)).collect::<Vec<_>>().join(", "); let entities = u
.entities
.iter()
.map(|h| entity_label(scene, *h))
.collect::<Vec<_>>()
.join(", ");
format!("{:?}: {entities}", u.kind) format!("{:?}: {entities}", u.kind)
}) })
.collect() .collect()
@@ -74,14 +75,20 @@ fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> {
None => counts.push((kind, 1)), None => counts.push((kind, 1)),
} }
} }
let summary = counts.iter().map(|(k, n)| format!("{k} ×{n}")).collect::<Vec<_>>().join(", "); let summary = counts
.iter()
.map(|(k, n)| format!("{k} ×{n}"))
.collect::<Vec<_>>()
.join(", ");
let mut body = column![].spacing(2); let mut body = column![].spacing(2);
for line in &lines { for line in &lines {
body = body.push(text(line.clone()).size(11)); body = body.push(text(line.clone()).size(11));
} }
tooltip( tooltip(
text(summary).size(11).style(muted_style), 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, TipPos::Top,
) )
.into() .into()
@@ -107,11 +114,15 @@ pub struct ParamEditorRow {
const GUTTER: f32 = 16.0; const GUTTER: f32 = 16.0;
fn muted_style(theme: &Theme) -> iced::widget::text::Style { 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 { 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 /// 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.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 /// Builds a scratch table from every row's *current* text and resolves each
@@ -184,7 +199,11 @@ fn preview(rows: &[ParamEditorRow]) -> Vec<Option<Result<f64, String>>> {
/// unapplied edits to a row's name in the buffer don't retroactively /// 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 /// relabel what's shown, same as the rest of this editor only takes effect
/// on Apply. /// 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 title = text(t!("Named Parameters")).size(15);
let hint = text(t!( 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." "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) .size(11)
.style(muted_style); .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 NAME_WIDTH: f32 = 120.0;
const VALUE_WIDTH: f32 = 100.0; const VALUE_WIDTH: f32 = 100.0;
const USED_BY_WIDTH: f32 = 160.0; const USED_BY_WIDTH: f32 = 160.0;
let head = container( let head = container(
row![ 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!("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!("Value")).size(11).style(muted_style))
container(text(t!("Used by")).size(11).style(muted_style)).width(Length::Fixed(USED_BY_WIDTH)), .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)), Space::new().width(Length::Fixed(30.0)),
] ]
.spacing(8), .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); let mut list = column![].spacing(3);
for (idx, row) in rows.iter().enumerate() { for (idx, row) in rows.iter().enumerate() {
let name_box = text_input(t!("name").as_ref(), &row.name) 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) .size(13)
.padding([3, 6]) .padding([3, 6])
.width(Length::Fixed(NAME_WIDTH)); .width(Length::Fixed(NAME_WIDTH));
let formula_box = text_input(t!("formula").as_ref(), &row.formula) 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) .size(13)
.padding([3, 6]) .padding([3, 6])
.width(sizing.width); .width(sizing.width);
let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() { 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(), Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(),
None => Space::new().into(), None => Space::new().into(),
}; };
let used_by_cell = used_by_cell(scene, row.name.trim()); 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)) let del = button(crate::ui::icons::themed_danger_text(
.on_press(Message::NamedParametersRemove(idx)) crate::ui::icons::CLOSE,
.padding([2, 6]) 12.0,
.style(button::danger); ))
.on_press(Message::NamedParametersRemove(idx))
.padding([2, 6])
.style(button::danger);
list = list.push( list = list.push(
row![ row![
name_box, name_box,
@@ -272,7 +312,10 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
.padding(12) .padding(12)
.width(sizing.width) .width(sizing.width)
.height(sizing.height) .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() .into()
} }
@@ -281,12 +324,18 @@ mod tests {
use super::*; use super::*;
fn row(name: &str, formula: &str) -> ParamEditorRow { 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] #[test]
fn preview_resolves_a_literal_and_a_cross_row_reference() { 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); let results = preview(&rows);
assert_eq!(results[0], Some(Ok(5.0))); assert_eq!(results[0], Some(Ok(5.0)));
assert_eq!(results[1], Some(Ok(11.5))); assert_eq!(results[1], Some(Ok(11.5)));
@@ -309,7 +358,10 @@ mod tests {
let results = preview(&rows); let results = preview(&rows);
// Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle); // 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. // 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"))); assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular")));
} }
@@ -332,27 +384,40 @@ mod tests {
let results = preview(&rows); let results = preview(&rows);
assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate"))); assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate")));
assert!(matches!(&results[1], 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] #[test]
fn usage_lines_names_the_constraint_kind_and_its_entities() { fn usage_lines_names_the_constraint_kind_and_its_entities() {
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
let mut scene = Scene::new(); let mut scene = Scene::new();
let line = scene.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points( let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(10.0, 0.0, 0.0), 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)], scene
Some(crate::scene::named_parameters::DrivingValue::Named("gap".to_string())), .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"); let lines = usage_lines(&scene, "gap");
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}"); 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] #[test]
+3 -76
View File
@@ -163,9 +163,6 @@ pub fn view_window<'a>(
crosshair_color: Option<[u8; 3]>, crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str, crosshair_color_input: &'a str,
lineweight_display_scale: i32, 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_space: &'a crate::app::config::ModelSpaceThemeConfig,
model_bg_input: &'a str, model_bg_input: &'a str,
paper_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) iced::widget::checkbox(write_dwg_native_constraints)
.on_toggle(Message::WriteDwgNativeConstraintsChanged) .on_toggle(Message::WriteDwgNativeConstraintsChanged)
.size(15), .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) .spacing(8)
.align_y(iced::Center), .align_y(iced::Center),
Space::new().height(6), Space::new().height(6),
text(crate::t!( text(crate::t!(
"Saves sketch constraints as AutoCAD/BricsCAD's own native objects too, alongside \ "Saves sketch constraints as native drawing objects alongside this app's own format. \
this app's own format, so other CAD software recognizes them. Off by default — \ Off by default because it adds file size. Existing native objects stay synchronized."
adds some file size on every save. A drawing that already has these objects keeps \
them up to date regardless of this setting."
)) ))
.size(11) .size(11)
.width(sizing.width), .width(sizing.width),
@@ -596,24 +591,6 @@ pub fn view_window<'a>(
}); });
let mut display = column![ 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), text(crate::tr!("options", "theme-section")).size(15),
Space::new().height(10), Space::new().height(10),
row![ row![
@@ -871,56 +848,6 @@ pub fn view_window<'a>(
)) ))
.size(11) .size(11)
.width(sizing.width), .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); let display_element = display.spacing(0).width(sizing.width);
File diff suppressed because it is too large. Load diff
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more